diff options
Diffstat (limited to 'spec')
1307 files changed, 42048 insertions, 19404 deletions
diff --git a/spec/benchmarks/banzai_benchmark.rb b/spec/benchmarks/banzai_benchmark.rb index a87414ba512..4cf079b2130 100644 --- a/spec/benchmarks/banzai_benchmark.rb +++ b/spec/benchmarks/banzai_benchmark.rb @@ -56,7 +56,7 @@ RSpec.describe 'GitLab Markdown Benchmark', :aggregate_failures do it 'benchmarks several pipelines' do path = 'images/example.jpg' gitaly_wiki_file = Gitlab::GitalyClient::WikiFile.new(path: path) - allow(wiki).to receive(:find_file).with(path).and_return(Gitlab::Git::WikiFile.new(gitaly_wiki_file)) + allow(wiki).to receive(:find_file).with(path, load_content: false).and_return(Gitlab::Git::WikiFile.new(gitaly_wiki_file)) allow(wiki).to receive(:wiki_base_path) { '/namespace1/gitlabhq/wikis' } puts "\n--> Benchmarking Full, Wiki, and Plain pipelines\n" diff --git a/spec/bin/feature_flag_spec.rb b/spec/bin/feature_flag_spec.rb index 710b1606923..de0db8ba256 100644 --- a/spec/bin/feature_flag_spec.rb +++ b/spec/bin/feature_flag_spec.rb @@ -265,16 +265,9 @@ RSpec.describe 'bin/feature-flag' do end describe '.read_ee_only' do - where(:type, :is_ee_only) do - :development | false - :licensed | true - end - - with_them do - let(:options) { OpenStruct.new(name: 'foo', type: type) } + let(:options) { OpenStruct.new(name: 'foo', type: :development) } - it { expect(described_class.read_ee_only(options)).to eq(is_ee_only) } - end + it { expect(described_class.read_ee_only(options)).to eq(false) } end end end diff --git a/spec/controllers/admin/application_settings_controller_spec.rb b/spec/controllers/admin/application_settings_controller_spec.rb index 71abf3191b8..2b562e2dd64 100644 --- a/spec/controllers/admin/application_settings_controller_spec.rb +++ b/spec/controllers/admin/application_settings_controller_spec.rb @@ -144,10 +144,10 @@ RSpec.describe Admin::ApplicationSettingsController do end it 'updates repository_storages_weighted setting' do - put :update, params: { application_setting: { repository_storages_weighted_default: 75 } } + put :update, params: { application_setting: { repository_storages_weighted: { default: 75 } } } expect(response).to redirect_to(general_admin_application_settings_path) - expect(ApplicationSetting.current.repository_storages_weighted_default).to eq(75) + expect(ApplicationSetting.current.repository_storages_weighted).to eq('default' => 75) end it 'updates kroki_formats setting' do diff --git a/spec/controllers/admin/instance_statistics_controller_spec.rb b/spec/controllers/admin/usage_trends_controller_spec.rb index c589e46857f..35fb005aacb 100644 --- a/spec/controllers/admin/instance_statistics_controller_spec.rb +++ b/spec/controllers/admin/usage_trends_controller_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Admin::InstanceStatisticsController do +RSpec.describe Admin::UsageTrendsController do let(:admin) { create(:user, :admin) } before do diff --git a/spec/controllers/concerns/spammable_actions_spec.rb b/spec/controllers/concerns/spammable_actions_spec.rb index 25d5398c9da..7bd5a76e60c 100644 --- a/spec/controllers/concerns/spammable_actions_spec.rb +++ b/spec/controllers/concerns/spammable_actions_spec.rb @@ -69,8 +69,11 @@ RSpec.describe SpammableActions do end context 'when spammable.render_recaptcha? is true' do + let(:spam_log) { instance_double(SpamLog, id: 123) } + let(:captcha_site_key) { 'abc123' } + before do - expect(spammable).to receive(:render_recaptcha?) { true } + expect(spammable).to receive(:render_recaptcha?).at_least(:once) { true } end context 'when format is :html' do @@ -83,24 +86,24 @@ RSpec.describe SpammableActions do context 'when format is :json' do let(:format) { :json } - let(:recaptcha_html) { '<recaptcha-html/>' } - it 'renders json with recaptcha_html' do - expect(controller).to receive(:render_to_string).with( - { - partial: 'shared/recaptcha_form', - formats: :html, - locals: { - spammable: spammable, - script: false, - has_submit: false - } - } - ) { recaptcha_html } + before do + expect(spammable).to receive(:spam?) { false } + expect(spammable).to receive(:spam_log) { spam_log } + expect(Gitlab::CurrentSettings).to receive(:recaptcha_site_key) { captcha_site_key } + end + it 'renders json with spam_action_response_fields' do subject - expect(json_response).to eq({ 'recaptcha_html' => recaptcha_html }) + expected_json_response = HashWithIndifferentAccess.new( + { + spam: false, + needs_captcha_response: true, + spam_log_id: spam_log.id, + captcha_site_key: captcha_site_key + }) + expect(json_response).to eq(expected_json_response) end end end diff --git a/spec/controllers/explore/projects_controller_spec.rb b/spec/controllers/explore/projects_controller_spec.rb index cfbd129388d..a2b62aa49d2 100644 --- a/spec/controllers/explore/projects_controller_spec.rb +++ b/spec/controllers/explore/projects_controller_spec.rb @@ -4,6 +4,8 @@ require 'spec_helper' RSpec.describe Explore::ProjectsController do shared_examples 'explore projects' do + let(:expected_default_sort) { 'latest_activity_desc' } + describe 'GET #index.json' do render_views @@ -12,6 +14,11 @@ RSpec.describe Explore::ProjectsController do end it { is_expected.to respond_with(:success) } + + it 'sets a default sort parameter' do + expect(controller.params[:sort]).to eq(expected_default_sort) + expect(assigns[:sort]).to eq(expected_default_sort) + end end describe 'GET #trending.json' do @@ -22,6 +29,11 @@ RSpec.describe Explore::ProjectsController do end it { is_expected.to respond_with(:success) } + + it 'sets a default sort parameter' do + expect(controller.params[:sort]).to eq(expected_default_sort) + expect(assigns[:sort]).to eq(expected_default_sort) + end end describe 'GET #starred.json' do @@ -32,6 +44,11 @@ RSpec.describe Explore::ProjectsController do end it { is_expected.to respond_with(:success) } + + it 'sets a default sort parameter' do + expect(controller.params[:sort]).to eq(expected_default_sort) + expect(assigns[:sort]).to eq(expected_default_sort) + end end describe 'GET #trending' do diff --git a/spec/controllers/groups/boards_controller_spec.rb b/spec/controllers/groups/boards_controller_spec.rb index a7480130e0a..6201cddecb0 100644 --- a/spec/controllers/groups/boards_controller_spec.rb +++ b/spec/controllers/groups/boards_controller_spec.rb @@ -29,7 +29,7 @@ RSpec.describe Groups::BoardsController do expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true) allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(true) - allow(Ability).to receive(:allowed?).with(user, :read_board, group).and_return(false) + allow(Ability).to receive(:allowed?).with(user, :read_issue_board, group).and_return(false) end it 'returns a not found 404 response' do @@ -74,7 +74,7 @@ RSpec.describe Groups::BoardsController do expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true) allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(true) - allow(Ability).to receive(:allowed?).with(user, :read_board, group).and_return(false) + allow(Ability).to receive(:allowed?).with(user, :read_issue_board, group).and_return(false) end it 'returns a not found 404 response' do @@ -111,7 +111,7 @@ RSpec.describe Groups::BoardsController do expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true) allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(true) - allow(Ability).to receive(:allowed?).with(user, :read_board, group).and_return(false) + allow(Ability).to receive(:allowed?).with(user, :read_issue_board, group).and_return(false) end it 'returns a not found 404 response' do diff --git a/spec/controllers/groups/clusters/applications_controller_spec.rb b/spec/controllers/groups/clusters/applications_controller_spec.rb index c3947c27399..5629e86c928 100644 --- a/spec/controllers/groups/clusters/applications_controller_spec.rb +++ b/spec/controllers/groups/clusters/applications_controller_spec.rb @@ -10,7 +10,8 @@ RSpec.describe Groups::Clusters::ApplicationsController do end shared_examples 'a secure endpoint' do - it { expect { subject }.to be_allowed_for(:admin) } + it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { subject }.to be_allowed_for(:admin) } + it('is denied for admin when admin mode is disabled') { expect { subject }.to be_denied_for(:admin) } it { expect { subject }.to be_allowed_for(:owner).of(group) } it { expect { subject }.to be_allowed_for(:maintainer).of(group) } it { expect { subject }.to be_denied_for(:developer).of(group) } diff --git a/spec/controllers/groups/clusters_controller_spec.rb b/spec/controllers/groups/clusters_controller_spec.rb index b287aca1e46..1334372a1f5 100644 --- a/spec/controllers/groups/clusters_controller_spec.rb +++ b/spec/controllers/groups/clusters_controller_spec.rb @@ -99,7 +99,8 @@ RSpec.describe Groups::ClustersController do describe 'security' do let(:cluster) { create(:cluster, :provided_by_gcp, cluster_type: :group_type, groups: [group]) } - it { expect { go }.to be_allowed_for(:admin) } + it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) } + it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) } it { expect { go }.to be_allowed_for(:owner).of(group) } it { expect { go }.to be_allowed_for(:maintainer).of(group) } it { expect { go }.to be_denied_for(:developer).of(group) } @@ -183,7 +184,8 @@ RSpec.describe Groups::ClustersController do include_examples 'GET new cluster shared examples' describe 'security' do - it { expect { go }.to be_allowed_for(:admin) } + it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) } + it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) } it { expect { go }.to be_allowed_for(:owner).of(group) } it { expect { go }.to be_allowed_for(:maintainer).of(group) } it { expect { go }.to be_denied_for(:developer).of(group) } @@ -316,7 +318,8 @@ RSpec.describe Groups::ClustersController do allow(WaitForClusterCreationWorker).to receive(:perform_in).and_return(nil) end - it { expect { go }.to be_allowed_for(:admin) } + it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) } + it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) } it { expect { go }.to be_allowed_for(:owner).of(group) } it { expect { go }.to be_allowed_for(:maintainer).of(group) } it { expect { go }.to be_denied_for(:developer).of(group) } @@ -418,7 +421,8 @@ RSpec.describe Groups::ClustersController do end describe 'security' do - it { expect { go }.to be_allowed_for(:admin) } + it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) } + it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) } it { expect { go }.to be_allowed_for(:owner).of(group) } it { expect { go }.to be_allowed_for(:maintainer).of(group) } it { expect { go }.to be_denied_for(:developer).of(group) } @@ -486,7 +490,8 @@ RSpec.describe Groups::ClustersController do allow(WaitForClusterCreationWorker).to receive(:perform_in) end - it { expect { post_create_aws }.to be_allowed_for(:admin) } + it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { post_create_aws }.to be_allowed_for(:admin) } + it('is denied for admin when admin mode is disabled') { expect { post_create_aws }.to be_denied_for(:admin) } it { expect { post_create_aws }.to be_allowed_for(:owner).of(group) } it { expect { post_create_aws }.to be_allowed_for(:maintainer).of(group) } it { expect { post_create_aws }.to be_denied_for(:developer).of(group) } @@ -544,7 +549,8 @@ RSpec.describe Groups::ClustersController do end end - it { expect { go }.to be_allowed_for(:admin) } + it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) } + it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) } it { expect { go }.to be_allowed_for(:owner).of(group) } it { expect { go }.to be_allowed_for(:maintainer).of(group) } it { expect { go }.to be_denied_for(:developer).of(group) } @@ -580,7 +586,8 @@ RSpec.describe Groups::ClustersController do end describe 'security' do - it { expect { go }.to be_allowed_for(:admin) } + it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) } + it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) } it { expect { go }.to be_allowed_for(:owner).of(group) } it { expect { go }.to be_allowed_for(:maintainer).of(group) } it { expect { go }.to be_denied_for(:developer).of(group) } @@ -619,7 +626,8 @@ RSpec.describe Groups::ClustersController do end describe 'security' do - it { expect { go }.to be_allowed_for(:admin) } + it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) } + it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) } it { expect { go }.to be_allowed_for(:owner).of(group) } it { expect { go }.to be_allowed_for(:maintainer).of(group) } it { expect { go }.to be_denied_for(:developer).of(group) } @@ -651,7 +659,8 @@ RSpec.describe Groups::ClustersController do end describe 'security' do - it { expect { go }.to be_allowed_for(:admin) } + it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) } + it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) } it { expect { go }.to be_allowed_for(:owner).of(group) } it { expect { go }.to be_allowed_for(:maintainer).of(group) } it { expect { go }.to be_denied_for(:developer).of(group) } @@ -759,7 +768,8 @@ RSpec.describe Groups::ClustersController do describe 'security' do let_it_be(:cluster) { create(:cluster, :provided_by_gcp, cluster_type: :group_type, groups: [group]) } - it { expect { go }.to be_allowed_for(:admin) } + it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) } + it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) } it { expect { go }.to be_allowed_for(:owner).of(group) } it { expect { go }.to be_allowed_for(:maintainer).of(group) } it { expect { go }.to be_denied_for(:developer).of(group) } @@ -827,7 +837,8 @@ RSpec.describe Groups::ClustersController do describe 'security' do let_it_be(:cluster) { create(:cluster, :provided_by_gcp, :production_environment, cluster_type: :group_type, groups: [group]) } - it { expect { go }.to be_allowed_for(:admin) } + it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) } + it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) } it { expect { go }.to be_allowed_for(:owner).of(group) } it { expect { go }.to be_allowed_for(:maintainer).of(group) } it { expect { go }.to be_denied_for(:developer).of(group) } diff --git a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb index 39cbdfb9123..83775dcdbdf 100644 --- a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb +++ b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb @@ -130,7 +130,7 @@ RSpec.describe Groups::DependencyProxyForContainersController do } end - it 'proxies status from the remote token request' do + it 'proxies status from the remote token request', :aggregate_failures do subject expect(response).to have_gitlab_http_status(:service_unavailable) @@ -147,7 +147,7 @@ RSpec.describe Groups::DependencyProxyForContainersController do } end - it 'proxies status from the remote manifest request' do + it 'proxies status from the remote manifest request', :aggregate_failures do subject expect(response).to have_gitlab_http_status(:bad_request) @@ -156,7 +156,7 @@ RSpec.describe Groups::DependencyProxyForContainersController do end it 'sends a file' do - expect(controller).to receive(:send_file).with(manifest.file.path, {}) + expect(controller).to receive(:send_file).with(manifest.file.path, type: manifest.content_type) subject end @@ -165,6 +165,10 @@ RSpec.describe Groups::DependencyProxyForContainersController do subject expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['Docker-Content-Digest']).to eq(manifest.digest) + expect(response.headers['Content-Length']).to eq(manifest.size) + expect(response.headers['Docker-Distribution-Api-Version']).to eq(DependencyProxy::DISTRIBUTION_API_VERSION) + expect(response.headers['Etag']).to eq("\"#{manifest.digest}\"") expect(response.headers['Content-Disposition']).to match(/^attachment/) end end @@ -207,7 +211,7 @@ RSpec.describe Groups::DependencyProxyForContainersController do } end - it 'proxies status from the remote blob request' do + it 'proxies status from the remote blob request', :aggregate_failures do subject expect(response).to have_gitlab_http_status(:bad_request) @@ -221,7 +225,7 @@ RSpec.describe Groups::DependencyProxyForContainersController do subject end - it 'returns Content-Disposition: attachment' do + it 'returns Content-Disposition: attachment', :aggregate_failures do subject expect(response).to have_gitlab_http_status(:ok) diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index 9e5f68820d9..cce61c4534b 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -4,17 +4,23 @@ require 'spec_helper' RSpec.describe GroupsController, factory_default: :keep do include ExternalAuthorizationServiceHelpers + include AdminModeHelper let_it_be_with_refind(:group) { create_default(:group, :public) } let_it_be_with_refind(:project) { create(:project, namespace: group) } let_it_be(:user) { create(:user) } - let_it_be(:admin) { create(:admin) } + let_it_be(:admin_with_admin_mode) { create(:admin) } + let_it_be(:admin_without_admin_mode) { create(:admin) } let_it_be(:group_member) { create(:group_member, group: group, user: user) } let_it_be(:owner) { group.add_owner(create(:user)).user } let_it_be(:maintainer) { group.add_maintainer(create(:user)).user } let_it_be(:developer) { group.add_developer(create(:user)).user } let_it_be(:guest) { group.add_guest(create(:user)).user } + before do + enable_admin_mode!(admin_with_admin_mode) + end + shared_examples 'member with ability to create subgroups' do it 'renders the new page' do sign_in(member) @@ -105,10 +111,10 @@ RSpec.describe GroupsController, factory_default: :keep do [true, false].each do |can_create_group_status| context "and can_create_group is #{can_create_group_status}" do before do - User.where(id: [admin, owner, maintainer, developer, guest]).update_all(can_create_group: can_create_group_status) + User.where(id: [admin_with_admin_mode, admin_without_admin_mode, owner, maintainer, developer, guest]).update_all(can_create_group: can_create_group_status) end - [:admin, :owner, :maintainer].each do |member_type| + [:admin_with_admin_mode, :owner, :maintainer].each do |member_type| context "and logged in as #{member_type.capitalize}" do it_behaves_like 'member with ability to create subgroups' do let(:member) { send(member_type) } @@ -116,7 +122,7 @@ RSpec.describe GroupsController, factory_default: :keep do end end - [:guest, :developer].each do |member_type| + [:guest, :developer, :admin_without_admin_mode].each do |member_type| context "and logged in as #{member_type.capitalize}" do it_behaves_like 'member without ability to create subgroups' do let(:member) { send(member_type) } @@ -856,6 +862,12 @@ RSpec.describe GroupsController, factory_default: :keep do end describe 'POST #export' do + let(:admin) { create(:admin) } + + before do + enable_admin_mode!(admin) + end + context 'when the group export feature flag is not enabled' do before do sign_in(admin) @@ -918,6 +930,12 @@ RSpec.describe GroupsController, factory_default: :keep do end describe 'GET #download_export' do + let(:admin) { create(:admin) } + + before do + enable_admin_mode!(admin) + end + context 'when there is a file available to download' do let(:export_file) { fixture_file_upload('spec/fixtures/group_export.tar.gz') } @@ -934,8 +952,6 @@ RSpec.describe GroupsController, factory_default: :keep do end context 'when there is no file available to download' do - let(:admin) { create(:admin) } - before do sign_in(admin) end diff --git a/spec/controllers/help_controller_spec.rb b/spec/controllers/help_controller_spec.rb index 629d9b50d73..71d9cab7280 100644 --- a/spec/controllers/help_controller_spec.rb +++ b/spec/controllers/help_controller_spec.rb @@ -132,6 +132,18 @@ RSpec.describe HelpController do expect(response).to redirect_to(new_user_session_path) end end + + context 'when two factor is required' do + before do + stub_two_factor_required + end + + it 'does not redirect to two factor auth' do + get :index + + expect(response).not_to redirect_to(profile_two_factor_auth_path) + end + end end describe 'GET #show' do @@ -152,6 +164,16 @@ RSpec.describe HelpController do end it_behaves_like 'documentation pages local render' + + context 'when two factor is required' do + before do + stub_two_factor_required + end + + it 'does not redirect to two factor auth' do + expect(response).not_to redirect_to(profile_two_factor_auth_path) + end + end end context 'when a custom help_page_documentation_url is set in database' do @@ -254,4 +276,9 @@ RSpec.describe HelpController do def stub_readme(content) expect_file_read(Rails.root.join('doc', 'README.md'), content: content) end + + def stub_two_factor_required + allow(controller).to receive(:two_factor_authentication_required?).and_return(true) + allow(controller).to receive(:current_user_requires_two_factor?).and_return(true) + end end diff --git a/spec/controllers/import/bulk_imports_controller_spec.rb b/spec/controllers/import/bulk_imports_controller_spec.rb index 08a54f112bb..b450318f6f7 100644 --- a/spec/controllers/import/bulk_imports_controller_spec.rb +++ b/spec/controllers/import/bulk_imports_controller_spec.rb @@ -123,7 +123,7 @@ RSpec.describe Import::BulkImportsController do it 'denies network request' do get :status - expect(controller).to redirect_to(new_group_path) + expect(controller).to redirect_to(new_group_path(anchor: 'import-group-pane')) expect(flash[:alert]).to eq('Specified URL cannot be used: "Only allowed schemes are http, https"') end end @@ -184,9 +184,15 @@ RSpec.describe Import::BulkImportsController do end describe 'POST create' do - let(:instance_url) { "http://fake-intance" } + let(:instance_url) { "http://fake-instance" } let(:bulk_import) { create(:bulk_import) } let(:pat) { "fake-pat" } + let(:bulk_import_params) do + [{ "source_type" => "group_entity", + "source_full_path" => "full_path", + "destination_name" => "destination_name", + "destination_namespace" => "root" }] + end before do session[:bulk_import_gitlab_access_token] = pat @@ -194,15 +200,9 @@ RSpec.describe Import::BulkImportsController do end it 'executes BulkImportService' do - bulk_import_params = [{ "source_type" => "group_entity", - "source_full_path" => "full_path", - "destination_name" => - "destination_name", - "destination_namespace" => "root" }] - expect_next_instance_of( BulkImportService, user, bulk_import_params, { url: instance_url, access_token: pat }) do |service| - allow(service).to receive(:execute).and_return(bulk_import) + allow(service).to receive(:execute).and_return(ServiceResponse.success(payload: bulk_import)) end post :create, params: { bulk_import: bulk_import_params } @@ -210,6 +210,19 @@ RSpec.describe Import::BulkImportsController do expect(response).to have_gitlab_http_status(:ok) expect(response.body).to eq({ id: bulk_import.id }.to_json) end + + it 'returns error when validation fails' do + error_response = ServiceResponse.error(message: 'Record invalid', http_status: :unprocessable_entity) + expect_next_instance_of( + BulkImportService, user, bulk_import_params, { url: instance_url, access_token: pat }) do |service| + allow(service).to receive(:execute).and_return(error_response) + end + + post :create, params: { bulk_import: bulk_import_params } + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(response.body).to eq({ error: 'Record invalid' }.to_json) + end end end diff --git a/spec/controllers/notification_settings_controller_spec.rb b/spec/controllers/notification_settings_controller_spec.rb deleted file mode 100644 index c4d67df15f7..00000000000 --- a/spec/controllers/notification_settings_controller_spec.rb +++ /dev/null @@ -1,202 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe NotificationSettingsController do - let(:project) { create(:project) } - let(:group) { create(:group, :internal) } - let(:user) { create(:user) } - - before do - project.add_developer(user) - end - - describe '#create' do - context 'when not authorized' do - it 'redirects to sign in page' do - post :create, - params: { - project_id: project.id, - notification_setting: { level: :participating } - } - - expect(response).to redirect_to(new_user_session_path) - end - end - - context 'when authorized' do - let(:notification_setting) { user.notification_settings_for(source) } - let(:custom_events) do - events = {} - - NotificationSetting.email_events(source).each do |event| - events[event.to_s] = true - end - - events - end - - before do - sign_in(user) - end - - context 'for projects' do - let(:source) { project } - - it 'creates notification setting' do - post :create, - params: { - project_id: project.id, - notification_setting: { level: :participating } - } - - expect(response).to have_gitlab_http_status(:ok) - expect(notification_setting.level).to eq("participating") - expect(notification_setting.user_id).to eq(user.id) - expect(notification_setting.source_id).to eq(project.id) - expect(notification_setting.source_type).to eq("Project") - end - - context 'with custom settings' do - it 'creates notification setting' do - post :create, - params: { - project_id: project.id, - notification_setting: { level: :custom }.merge(custom_events) - } - - expect(response).to have_gitlab_http_status(:ok) - expect(notification_setting.level).to eq("custom") - - custom_events.each do |event, value| - expect(notification_setting.event_enabled?(event)).to eq(value) - end - end - end - end - - context 'for groups' do - let(:source) { group } - - it 'creates notification setting' do - post :create, - params: { - namespace_id: group.id, - notification_setting: { level: :watch } - } - - expect(response).to have_gitlab_http_status(:ok) - expect(notification_setting.level).to eq("watch") - expect(notification_setting.user_id).to eq(user.id) - expect(notification_setting.source_id).to eq(group.id) - expect(notification_setting.source_type).to eq("Namespace") - end - - context 'with custom settings' do - it 'creates notification setting' do - post :create, - params: { - namespace_id: group.id, - notification_setting: { level: :custom }.merge(custom_events) - } - - expect(response).to have_gitlab_http_status(:ok) - expect(notification_setting.level).to eq("custom") - - custom_events.each do |event, value| - expect(notification_setting.event_enabled?(event)).to eq(value) - end - end - end - end - end - - context 'not authorized' do - let(:private_project) { create(:project, :private) } - - before do - sign_in(user) - end - - it 'returns 404' do - post :create, - params: { - project_id: private_project.id, - notification_setting: { level: :participating } - } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - end - - describe '#update' do - let(:notification_setting) { user.global_notification_setting } - - context 'when not authorized' do - it 'redirects to sign in page' do - put :update, - params: { - id: notification_setting, - notification_setting: { level: :participating } - } - - expect(response).to redirect_to(new_user_session_path) - end - end - - context 'when authorized' do - before do - sign_in(user) - end - - it 'returns success' do - put :update, - params: { - id: notification_setting, - notification_setting: { level: :participating } - } - - expect(response).to have_gitlab_http_status(:ok) - end - - context 'and setting custom notification setting' do - let(:custom_events) do - events = {} - - notification_setting.email_events.each do |event| - events[event] = "true" - end - end - - it 'returns success' do - put :update, - params: { - id: notification_setting, - notification_setting: { level: :participating, events: custom_events } - } - - expect(response).to have_gitlab_http_status(:ok) - end - end - end - - context 'not authorized' do - let(:other_user) { create(:user) } - - before do - sign_in(other_user) - end - - it 'returns 404' do - put :update, - params: { - id: notification_setting, - notification_setting: { level: :participating } - } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - end -end diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb index 68551ce4858..c9a76049e19 100644 --- a/spec/controllers/projects/blob_controller_spec.rb +++ b/spec/controllers/projects/blob_controller_spec.rb @@ -20,8 +20,8 @@ RSpec.describe Projects::BlobController do project.add_maintainer(user) sign_in(user) - stub_experiment(ci_syntax_templates: experiment_active) - stub_experiment_for_subject(ci_syntax_templates: in_experiment_group) + stub_experiment(ci_syntax_templates_b: experiment_active) + stub_experiment_for_subject(ci_syntax_templates_b: in_experiment_group) end context 'when the experiment is not active' do @@ -35,48 +35,62 @@ RSpec.describe Projects::BlobController do end end - context 'when the experiment is active and the user is in the control group' do + context 'when the experiment is active' do let(:experiment_active) { true } - let(:in_experiment_group) { false } - - it 'records the experiment user in the control group' do - expect(Experiment).to receive(:add_user) - .with(:ci_syntax_templates, :control, user, namespace_id: project.namespace_id) - request - end - end + context 'when the user is in the control group' do + let(:in_experiment_group) { false } - context 'when the experiment is active and the user is in the experimental group' do - let(:experiment_active) { true } - let(:in_experiment_group) { true } - - it 'records the experiment user in the experimental group' do - expect(Experiment).to receive(:add_user) - .with(:ci_syntax_templates, :experimental, user, namespace_id: project.namespace_id) + it 'records the experiment user in the control group' do + expect(Experiment).to receive(:add_user) + .with(:ci_syntax_templates_b, :control, user, namespace_id: project.namespace_id) - request + request + end end - context 'when requesting a non default config file type' do - let(:file_name) { '.non_default_ci_config' } - let(:project) { create(:project, :public, :repository, ci_config_path: file_name) } + context 'when the user is in the experimental group' do + let(:in_experiment_group) { true } it 'records the experiment user in the experimental group' do expect(Experiment).to receive(:add_user) - .with(:ci_syntax_templates, :experimental, user, namespace_id: project.namespace_id) + .with(:ci_syntax_templates_b, :experimental, user, namespace_id: project.namespace_id) request end - end - context 'when requesting a different file type' do - let(:file_name) { '.gitignore' } + context 'when requesting a non default config file type' do + let(:file_name) { '.non_default_ci_config' } + let(:project) { create(:project, :public, :repository, ci_config_path: file_name) } - it 'does not record the experiment user' do - expect(Experiment).not_to receive(:add_user) + it 'records the experiment user in the experimental group' do + expect(Experiment).to receive(:add_user) + .with(:ci_syntax_templates_b, :experimental, user, namespace_id: project.namespace_id) - request + request + end + end + + context 'when requesting a different file type' do + let(:file_name) { '.gitignore' } + + it 'does not record the experiment user' do + expect(Experiment).not_to receive(:add_user) + + request + end + end + + context 'when the group is created longer than 90 days ago' do + before do + project.namespace.update_attribute(:created_at, 91.days.ago) + end + + it 'does not record the experiment user' do + expect(Experiment).not_to receive(:add_user) + + request + end end end end diff --git a/spec/controllers/projects/boards_controller_spec.rb b/spec/controllers/projects/boards_controller_spec.rb index 1ed61e0990f..cde3a8d4761 100644 --- a/spec/controllers/projects/boards_controller_spec.rb +++ b/spec/controllers/projects/boards_controller_spec.rb @@ -34,7 +34,7 @@ RSpec.describe Projects::BoardsController do before do expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true) - allow(Ability).to receive(:allowed?).with(user, :read_board, project).and_return(false) + allow(Ability).to receive(:allowed?).with(user, :read_issue_board, project).and_return(false) end it 'returns a not found 404 response' do @@ -78,7 +78,7 @@ RSpec.describe Projects::BoardsController do before do expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true) - allow(Ability).to receive(:allowed?).with(user, :read_board, project).and_return(false) + allow(Ability).to receive(:allowed?).with(user, :read_issue_board, project).and_return(false) end it 'returns a not found 404 response' do @@ -134,7 +134,7 @@ RSpec.describe Projects::BoardsController do before do expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true) - allow(Ability).to receive(:allowed?).with(user, :read_board, project).and_return(false) + allow(Ability).to receive(:allowed?).with(user, :read_issue_board, project).and_return(false) end it 'returns a not found 404 response' do @@ -172,7 +172,7 @@ RSpec.describe Projects::BoardsController do before do expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true) - allow(Ability).to receive(:allowed?).with(user, :read_board, project).and_return(false) + allow(Ability).to receive(:allowed?).with(user, :read_issue_board, project).and_return(false) end it 'returns a not found 404 response' do diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb index 14a5e7da7d2..a99db2664a7 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -648,7 +648,9 @@ RSpec.describe Projects::BranchesController do end it 'sets active and stale branches' do - expect(assigns[:active_branches]).to eq([]) + expect(assigns[:active_branches].map(&:name)).not_to include( + "feature", "improve/awesome", "merge-test", "markdown", "feature_conflict", "'test'" + ) expect(assigns[:stale_branches].map(&:name)).to eq( ["feature", "improve/awesome", "merge-test", "markdown", "feature_conflict", "'test'"] ) @@ -660,7 +662,9 @@ RSpec.describe Projects::BranchesController do end it 'sets active and stale branches' do - expect(assigns[:active_branches]).to eq([]) + expect(assigns[:active_branches].map(&:name)).not_to include( + "feature", "improve/awesome", "merge-test", "markdown", "feature_conflict", "'test'" + ) expect(assigns[:stale_branches].map(&:name)).to eq( ["feature", "improve/awesome", "merge-test", "markdown", "feature_conflict", "'test'"] ) diff --git a/spec/controllers/projects/ci/daily_build_group_report_results_controller_spec.rb b/spec/controllers/projects/ci/daily_build_group_report_results_controller_spec.rb index 81318b49cd9..3c4376909f8 100644 --- a/spec/controllers/projects/ci/daily_build_group_report_results_controller_spec.rb +++ b/spec/controllers/projects/ci/daily_build_group_report_results_controller_spec.rb @@ -4,29 +4,25 @@ require 'spec_helper' RSpec.describe Projects::Ci::DailyBuildGroupReportResultsController do describe 'GET index' do - let(:project) { create(:project, :public, :repository) } - let(:ref_path) { 'refs/heads/master' } - let(:param_type) { 'coverage' } - let(:start_date) { '2019-12-10' } - let(:end_date) { '2020-03-09' } - let(:allowed_to_read) { true } - let(:user) { create(:user) } - let(:feature_enabled?) { true } + let_it_be(:project) { create(:project, :public, :repository) } + let_it_be(:ref_path) { 'refs/heads/master' } + let_it_be(:param_type) { 'coverage' } + let_it_be(:start_date) { '2019-12-10' } + let_it_be(:end_date) { '2020-03-09' } + let_it_be(:allowed_to_read) { true } + let_it_be(:user) { create(:user) } + let_it_be(:rspec_coverage_1) { create_daily_coverage('rspec', 79.0, '2020-03-09') } + let_it_be(:rspec_coverage_2) { create_daily_coverage('rspec', 77.0, '2020-03-08') } + let_it_be(:karma_coverage) { create_daily_coverage('karma', 81.0, '2019-12-10') } + let_it_be(:minitest_coverage) { create_daily_coverage('minitest', 67.0, '2019-12-09') } + let_it_be(:mocha_coverage) { create_daily_coverage('mocha', 71.0, '2019-12-09') } before do - create_daily_coverage('rspec', 79.0, '2020-03-09') - create_daily_coverage('rspec', 77.0, '2020-03-08') - create_daily_coverage('karma', 81.0, '2019-12-10') - create_daily_coverage('minitest', 67.0, '2019-12-09') - create_daily_coverage('mocha', 71.0, '2019-12-09') - sign_in(user) allow(Ability).to receive(:allowed?).and_call_original allow(Ability).to receive(:allowed?).with(user, :read_build_report_results, project).and_return(allowed_to_read) - stub_feature_flags(coverage_data_new_finder: feature_enabled?) - get :index, params: { namespace_id: project.namespace, project_id: project, @@ -140,33 +136,13 @@ RSpec.describe Projects::Ci::DailyBuildGroupReportResultsController do context 'when format is JSON' do let(:format) { :json } - context 'when coverage_data_new_finder flag is enabled' do - let(:feature_enabled?) { true } - - it_behaves_like 'JSON results' - end - - context 'when coverage_data_new_finder flag is disabled' do - let(:feature_enabled?) { false } - - it_behaves_like 'JSON results' - end + it_behaves_like 'JSON results' end context 'when format is CSV' do let(:format) { :csv } - context 'when coverage_data_new_finder flag is enabled' do - let(:feature_enabled?) { true } - - it_behaves_like 'CSV results' - end - - context 'when coverage_data_new_finder flag is disabled' do - let(:feature_enabled?) { false } - - it_behaves_like 'CSV results' - end + it_behaves_like 'CSV results' end end diff --git a/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb b/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb index 1bf6ff95c44..942402a6d00 100644 --- a/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb +++ b/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb @@ -36,18 +36,5 @@ RSpec.describe Projects::Ci::PipelineEditorController do expect(response).to have_gitlab_http_status(:not_found) end end - - context 'when ci_pipeline_editor_page feature flag is disabled' do - before do - stub_feature_flags(ci_pipeline_editor_page: false) - project.add_developer(user) - - get :show, params: { namespace_id: project.namespace, project_id: project } - end - - it 'responds with 404' do - expect(response).to have_gitlab_http_status(:not_found) - end - end end end diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb index 706bf787b2d..2d7f036be21 100644 --- a/spec/controllers/projects/commit_controller_spec.rb +++ b/spec/controllers/projects/commit_controller_spec.rb @@ -9,6 +9,8 @@ RSpec.describe Projects::CommitController do let(:commit) { project.commit("master") } let(:master_pickable_sha) { '7d3b0f7cff5f37573aea97cebfd5692ea1689924' } let(:master_pickable_commit) { project.commit(master_pickable_sha) } + let(:pipeline) { create(:ci_pipeline, project: project, ref: project.default_branch, sha: commit.sha, status: :running) } + let(:build) { create(:ci_build, pipeline: pipeline, status: :running) } before do sign_in(user) @@ -33,6 +35,19 @@ RSpec.describe Projects::CommitController do expect(response).to be_ok end + + context 'when a pipeline job is running' do + before do + build.run + end + + it 'defines last pipeline information' do + go(id: commit.id) + + expect(assigns(:last_pipeline)).to have_attributes(id: pipeline.id, status: 'running') + expect(assigns(:last_pipeline_stages)).not_to be_empty + end + end end context 'with invalid id' do @@ -363,15 +378,22 @@ RSpec.describe Projects::CommitController do context 'when the commit exists' do context 'when the commit has pipelines' do before do - create(:ci_pipeline, project: project, sha: commit.id) + build.run end context 'when rendering a HTML format' do - it 'shows pipelines' do + before do get_pipelines(id: commit.id) + end + it 'shows pipelines' do expect(response).to be_ok end + + it 'defines last pipeline information' do + expect(assigns(:last_pipeline)).to have_attributes(id: pipeline.id, status: 'running') + expect(assigns(:last_pipeline_stages)).not_to be_empty + end end context 'when rendering a JSON format' do diff --git a/spec/controllers/projects/compare_controller_spec.rb b/spec/controllers/projects/compare_controller_spec.rb index 6aa4bfe235b..80a6d3960cd 100644 --- a/spec/controllers/projects/compare_controller_spec.rb +++ b/spec/controllers/projects/compare_controller_spec.rb @@ -3,8 +3,21 @@ require 'spec_helper' RSpec.describe Projects::CompareController do - let(:project) { create(:project, :repository) } - let(:user) { create(:user) } + include ProjectForksHelper + + using RSpec::Parameterized::TableSyntax + + let_it_be(:project) { create(:project, :repository, :public) } + let_it_be(:user) { create(:user) } + + let(:private_fork) { fork_project(project, nil, repository: true).tap { |fork| fork.update!(visibility: 'private') } } + let(:public_fork) do + fork_project(project, nil, repository: true).tap do |fork| + fork.update!(visibility: 'public') + # Create a reference that only exists in this project + fork.repository.create_ref('refs/heads/improve/awesome', 'refs/heads/improve/more-awesome') + end + end before do sign_in(user) @@ -32,18 +45,20 @@ RSpec.describe Projects::CompareController do { namespace_id: project.namespace, project_id: project, - from: source_ref, - to: target_ref, + from_project_id: from_project_id, + from: from_ref, + to: to_ref, w: whitespace } end let(:whitespace) { nil } - context 'when the refs exist' do + context 'when the refs exist in the same project' do context 'when we set the white space param' do - let(:source_ref) { "08f22f25" } - let(:target_ref) { "66eceea0" } + let(:from_project_id) { nil } + let(:from_ref) { '08f22f25' } + let(:to_ref) { '66eceea0' } let(:whitespace) { 1 } it 'shows some diffs with ignore whitespace change option' do @@ -60,8 +75,9 @@ RSpec.describe Projects::CompareController do end context 'when we do not set the white space param' do - let(:source_ref) { "improve%2Fawesome" } - let(:target_ref) { "feature" } + let(:from_project_id) { nil } + let(:from_ref) { 'improve%2Fawesome' } + let(:to_ref) { 'feature' } let(:whitespace) { nil } it 'sets the diffs and commits ivars' do @@ -74,9 +90,40 @@ RSpec.describe Projects::CompareController do end end + context 'when the refs exist in different projects that the user can see' do + let(:from_project_id) { public_fork.id } + let(:from_ref) { 'improve%2Fmore-awesome' } + let(:to_ref) { 'feature' } + let(:whitespace) { nil } + + it 'shows the diff' do + show_request + + expect(response).to be_successful + expect(assigns(:diffs).diff_files.first).not_to be_nil + expect(assigns(:commits).length).to be >= 1 + end + end + + context 'when the refs exist in different projects but the user cannot see' do + let(:from_project_id) { private_fork.id } + let(:from_ref) { 'improve%2Fmore-awesome' } + let(:to_ref) { 'feature' } + let(:whitespace) { nil } + + it 'does not show the diff' do + show_request + + expect(response).to be_successful + expect(assigns(:diffs)).to be_empty + expect(assigns(:commits)).to be_empty + end + end + context 'when the source ref does not exist' do - let(:source_ref) { 'non-existent-source-ref' } - let(:target_ref) { "feature" } + let(:from_project_id) { nil } + let(:from_ref) { 'non-existent-source-ref' } + let(:to_ref) { 'feature' } it 'sets empty diff and commit ivars' do show_request @@ -88,8 +135,9 @@ RSpec.describe Projects::CompareController do end context 'when the target ref does not exist' do - let(:target_ref) { 'non-existent-target-ref' } - let(:source_ref) { "improve%2Fawesome" } + let(:from_project_id) { nil } + let(:from_ref) { 'improve%2Fawesome' } + let(:to_ref) { 'non-existent-target-ref' } it 'sets empty diff and commit ivars' do show_request @@ -101,8 +149,9 @@ RSpec.describe Projects::CompareController do end context 'when the target ref is invalid' do - let(:target_ref) { "master%' AND 2554=4423 AND '%'='" } - let(:source_ref) { "improve%2Fawesome" } + let(:from_project_id) { nil } + let(:from_ref) { 'improve%2Fawesome' } + let(:to_ref) { "master%' AND 2554=4423 AND '%'='" } it 'shows a flash message and redirects' do show_request @@ -113,8 +162,9 @@ RSpec.describe Projects::CompareController do end context 'when the source ref is invalid' do - let(:source_ref) { "master%' AND 2554=4423 AND '%'='" } - let(:target_ref) { "improve%2Fawesome" } + let(:from_project_id) { nil } + let(:from_ref) { "master%' AND 2554=4423 AND '%'='" } + let(:to_ref) { 'improve%2Fawesome' } it 'shows a flash message and redirects' do show_request @@ -126,24 +176,33 @@ RSpec.describe Projects::CompareController do end describe 'GET diff_for_path' do - def diff_for_path(extra_params = {}) - params = { + subject(:diff_for_path_request) { get :diff_for_path, params: request_params } + + let(:request_params) do + { + from_project_id: from_project_id, + from: from_ref, + to: to_ref, namespace_id: project.namespace, - project_id: project + project_id: project, + old_path: old_path, + new_path: new_path } - - get :diff_for_path, params: params.merge(extra_params) end let(:existing_path) { 'files/ruby/feature.rb' } - let(:source_ref) { "improve%2Fawesome" } - let(:target_ref) { "feature" } - context 'when the source and target refs exist' do + let(:from_project_id) { nil } + let(:from_ref) { 'improve%2Fawesome' } + let(:to_ref) { 'feature' } + let(:old_path) { existing_path } + let(:new_path) { existing_path } + + context 'when the source and target refs exist in the same project' do context 'when the user has access target the project' do context 'when the path exists in the diff' do it 'disables diff notes' do - diff_for_path(from: source_ref, to: target_ref, old_path: existing_path, new_path: existing_path) + diff_for_path_request expect(assigns(:diff_notes_disabled)).to be_truthy end @@ -154,16 +213,17 @@ RSpec.describe Projects::CompareController do meth.call(diffs) end - diff_for_path(from: source_ref, to: target_ref, old_path: existing_path, new_path: existing_path) + diff_for_path_request end end context 'when the path does not exist in the diff' do - before do - diff_for_path(from: source_ref, to: target_ref, old_path: existing_path.succ, new_path: existing_path.succ) - end + let(:old_path) { existing_path.succ } + let(:new_path) { existing_path.succ } it 'returns a 404' do + diff_for_path_request + expect(response).to have_gitlab_http_status(:not_found) end end @@ -172,31 +232,56 @@ RSpec.describe Projects::CompareController do context 'when the user does not have access target the project' do before do project.team.truncate - diff_for_path(from: source_ref, to: target_ref, old_path: existing_path, new_path: existing_path) end it 'returns a 404' do + diff_for_path_request + expect(response).to have_gitlab_http_status(:not_found) end end end - context 'when the source ref does not exist' do - before do - diff_for_path(from: source_ref.succ, to: target_ref, old_path: existing_path, new_path: existing_path) + context 'when the source and target refs exist in different projects and the user can see' do + let(:from_project_id) { public_fork.id } + let(:from_ref) { 'improve%2Fmore-awesome' } + + it 'shows the diff for that path' do + expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs| + expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path) + meth.call(diffs) + end + + diff_for_path_request + end + end + + context 'when the source and target refs exist in different projects and the user cannot see' do + let(:from_project_id) { private_fork.id } + + it 'does not show the diff for that path' do + diff_for_path_request + + expect(response).to have_gitlab_http_status(:not_found) end + end + + context 'when the source ref does not exist' do + let(:from_ref) { 'this-ref-does-not-exist' } it 'returns a 404' do + diff_for_path_request + expect(response).to have_gitlab_http_status(:not_found) end end context 'when the target ref does not exist' do - before do - diff_for_path(from: source_ref, to: target_ref.succ, old_path: existing_path, new_path: existing_path) - end + let(:to_ref) { 'this-ref-does-not-exist' } it 'returns a 404' do + diff_for_path_request + expect(response).to have_gitlab_http_status(:not_found) end end @@ -209,53 +294,54 @@ RSpec.describe Projects::CompareController do { namespace_id: project.namespace, project_id: project, - from: source_ref, - to: target_ref + from_project_id: from_project_id, + from: from_ref, + to: to_ref } end context 'when sending valid params' do - let(:source_ref) { "improve%2Fawesome" } - let(:target_ref) { "feature" } + let(:from_ref) { 'awesome%2Ffeature' } + let(:to_ref) { 'feature' } - it 'redirects back to show' do - create_request - - expect(response).to redirect_to(project_compare_path(project, to: target_ref, from: source_ref)) - end - end + context 'without a from_project_id' do + let(:from_project_id) { nil } - context 'when sending invalid params' do - context 'when the source ref is empty and target ref is set' do - let(:source_ref) { '' } - let(:target_ref) { 'master' } - - it 'redirects back to index and preserves the target ref' do + it 'redirects to the show page' do create_request - expect(response).to redirect_to(project_compare_index_path(project, to: target_ref)) + expect(response).to redirect_to(project_compare_path(project, from: from_ref, to: to_ref)) end end - context 'when the target ref is empty and source ref is set' do - let(:source_ref) { 'master' } - let(:target_ref) { '' } + context 'with a from_project_id' do + let(:from_project_id) { 'something or another' } - it 'redirects back to index and preserves source ref' do + it 'redirects to the show page without interpreting from_project_id' do create_request - expect(response).to redirect_to(project_compare_index_path(project, from: source_ref)) + expect(response).to redirect_to(project_compare_path(project, from: from_ref, to: to_ref, from_project_id: from_project_id)) end end + end + + context 'when sending invalid params' do + where(:from_ref, :to_ref, :from_project_id, :expected_redirect_params) do + '' | '' | '' | {} + 'main' | '' | '' | { from: 'main' } + '' | 'main' | '' | { to: 'main' } + '' | '' | '1' | { from_project_id: 1 } + 'main' | '' | '1' | { from: 'main', from_project_id: 1 } + '' | 'main' | '1' | { to: 'main', from_project_id: 1 } + end - context 'when the target and source ref are empty' do - let(:source_ref) { '' } - let(:target_ref) { '' } + with_them do + let(:expected_redirect) { project_compare_index_path(project, expected_redirect_params) } - it 'redirects back to index' do + it 'redirects back to the index' do create_request - expect(response).to redirect_to(namespace_project_compare_index_path) + expect(response).to redirect_to(expected_redirect) end end end @@ -268,15 +354,15 @@ RSpec.describe Projects::CompareController do { namespace_id: project.namespace, project_id: project, - from: source_ref, - to: target_ref, + from: from_ref, + to: to_ref, format: :json } end context 'when the source and target refs exist' do - let(:source_ref) { "improve%2Fawesome" } - let(:target_ref) { "feature" } + let(:from_ref) { 'improve%2Fawesome' } + let(:to_ref) { 'feature' } context 'when the user has access to the project' do render_views @@ -285,14 +371,14 @@ RSpec.describe Projects::CompareController do let(:non_signature_commit) { build(:commit, project: project, safe_message: "message", sha: 'non_signature_commit') } before do - escaped_source_ref = Addressable::URI.unescape(source_ref) - escaped_target_ref = Addressable::URI.unescape(target_ref) + escaped_from_ref = Addressable::URI.unescape(from_ref) + escaped_to_ref = Addressable::URI.unescape(to_ref) - compare_service = CompareService.new(project, escaped_target_ref) - compare = compare_service.execute(project, escaped_source_ref) + compare_service = CompareService.new(project, escaped_to_ref) + compare = compare_service.execute(project, escaped_from_ref) - expect(CompareService).to receive(:new).with(project, escaped_target_ref).and_return(compare_service) - expect(compare_service).to receive(:execute).with(project, escaped_source_ref).and_return(compare) + expect(CompareService).to receive(:new).with(project, escaped_to_ref).and_return(compare_service) + expect(compare_service).to receive(:execute).with(project, escaped_from_ref).and_return(compare) expect(compare).to receive(:commits).and_return([signature_commit, non_signature_commit]) expect(non_signature_commit).to receive(:has_signature?).and_return(false) @@ -313,6 +399,7 @@ RSpec.describe Projects::CompareController do context 'when the user does not have access to the project' do before do project.team.truncate + project.update!(visibility: 'private') end it 'returns a 404' do @@ -324,8 +411,8 @@ RSpec.describe Projects::CompareController do end context 'when the source ref does not exist' do - let(:source_ref) { 'non-existent-ref-source' } - let(:target_ref) { "feature" } + let(:from_ref) { 'non-existent-ref-source' } + let(:to_ref) { 'feature' } it 'returns no signatures' do signatures_request @@ -336,8 +423,8 @@ RSpec.describe Projects::CompareController do end context 'when the target ref does not exist' do - let(:target_ref) { 'non-existent-ref-target' } - let(:source_ref) { "improve%2Fawesome" } + let(:from_ref) { 'improve%2Fawesome' } + let(:to_ref) { 'non-existent-ref-target' } it 'returns no signatures' do signatures_request diff --git a/spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb b/spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb index f664604ac15..e0f86876f67 100644 --- a/spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb +++ b/spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb @@ -37,13 +37,24 @@ RSpec.describe Projects::DesignManagement::Designs::RawImagesController do # For security, .svg images should only ever be served with Content-Disposition: attachment. # If this specs ever fails we must assess whether we should be serving svg images. # See https://gitlab.com/gitlab-org/gitlab/issues/12771 - it 'serves files with `Content-Disposition: attachment`' do + it 'serves files with `Content-Disposition` header set to attachment plus the filename' do subject - expect(response.header['Content-Disposition']).to eq('attachment') + expect(response.header['Content-Disposition']).to match "attachment; filename=\"#{design.filename}\"" expect(response).to have_gitlab_http_status(:ok) end + context 'when the feature flag attachment_with_filename is disabled' do + it 'serves files with just `attachment` in the disposition header' do + stub_feature_flags(attachment_with_filename: false) + + subject + + expect(response.header['Content-Disposition']).to eq('attachment') + expect(response).to have_gitlab_http_status(:ok) + end + end + it 'serves files with Workhorse' do subject diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 81ffd2c4512..74062038248 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -9,6 +9,7 @@ RSpec.describe Projects::IssuesController do let_it_be(:project, reload: true) { create(:project) } let_it_be(:user, reload: true) { create(:user) } let(:issue) { create(:issue, project: project) } + let(:spam_action_response_fields) { { 'stub_spam_action_response_fields' => true } } describe "GET #index" do context 'external issue tracker' do @@ -613,12 +614,15 @@ RSpec.describe Projects::IssuesController do context 'when allow_possible_spam feature flag is false' do before do stub_feature_flags(allow_possible_spam: false) + expect(controller).to(receive(:spam_action_response_fields).with(issue)) do + spam_action_response_fields + end end - it 'renders json with recaptcha_html' do + it 'renders json with spam_action_response_fields' do subject - expect(json_response).to have_key('recaptcha_html') + expect(json_response).to eq(spam_action_response_fields) end end @@ -948,12 +952,17 @@ RSpec.describe Projects::IssuesController do context 'renders properly' do render_views - it 'renders recaptcha_html json response' do + before do + expect(controller).to(receive(:spam_action_response_fields).with(issue)) do + spam_action_response_fields + end + end + + it 'renders spam_action_response_fields json response' do update_issue - expect(response).to have_gitlab_http_status(:ok) - expect(json_response).to have_key('recaptcha_html') - expect(json_response['recaptcha_html']).not_to be_empty + expect(response).to have_gitlab_http_status(:conflict) + expect(json_response).to eq(spam_action_response_fields) end end end diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 9b37c46fd86..93d5e7eff6c 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -2048,21 +2048,6 @@ RSpec.describe Projects::MergeRequestsController do end end - context 'with SELECT FOR UPDATE lock' do - before do - stub_feature_flags(merge_request_rebase_nowait_lock: false) - end - - it 'executes rebase' do - allow_any_instance_of(MergeRequest).to receive(:with_lock).with(true).and_call_original - expect(RebaseWorker).to receive(:perform_async) - - post_rebase - - expect(response).to have_gitlab_http_status(:ok) - end - end - context 'with NOWAIT lock' do it 'returns a 409' do allow_any_instance_of(MergeRequest).to receive(:with_lock).with('FOR UPDATE NOWAIT').and_raise(ActiveRecord::LockWaitTimeout) diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index edebaf294c4..add249e2c74 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -150,7 +150,7 @@ RSpec.describe Projects::NotesController do end it 'returns an empty page of notes' do - expect(Gitlab::EtagCaching::Middleware).to receive(:skip!) + expect(Gitlab::EtagCaching::Middleware).not_to receive(:skip!) request.headers['X-Last-Fetched-At'] = microseconds(Time.zone.now) @@ -169,8 +169,6 @@ RSpec.describe Projects::NotesController do end it 'returns all notes' do - expect(Gitlab::EtagCaching::Middleware).to receive(:skip!) - get :index, params: request_params expect(json_response['notes'].count).to eq((page_1 + page_2 + page_3).size + 1) @@ -764,49 +762,9 @@ RSpec.describe Projects::NotesController do end end - context 'when the endpoint receives requests above the limit' do - before do - stub_application_setting(notes_create_limit: 3) - end - - it 'prevents from creating more notes', :request_store do - 3.times { create! } - - expect { create! } - .to change { Gitlab::GitalyClient.get_request_count }.by(0) - - create! - expect(response.body).to eq(_('This endpoint has been requested too many times. Try again later.')) - expect(response).to have_gitlab_http_status(:too_many_requests) - end - - it 'logs the event in auth.log' do - attributes = { - message: 'Application_Rate_Limiter_Request', - env: :notes_create_request_limit, - remote_ip: '0.0.0.0', - request_method: 'POST', - path: "/#{project.full_path}/notes", - user_id: user.id, - username: user.username - } - - expect(Gitlab::AuthLogger).to receive(:error).with(attributes).once - - project.add_developer(user) - sign_in(user) - - 4.times { create! } - end - - it 'allows user in allow-list to create notes, even if the case is different' do - user.update_attribute(:username, user.username.titleize) - stub_application_setting(notes_create_limit_allowlist: ["#{user.username.downcase}"]) - 3.times { create! } - - create! - expect(response).to have_gitlab_http_status(:found) - end + it_behaves_like 'request exceeding rate limit', :clean_gitlab_redis_cache do + let(:params) { request_params.except(:format) } + let(:request_full_path) { project_notes_path(project) } end end diff --git a/spec/controllers/projects/security/configuration_controller_spec.rb b/spec/controllers/projects/security/configuration_controller_spec.rb index ef255d1efd0..848db16fb02 100644 --- a/spec/controllers/projects/security/configuration_controller_spec.rb +++ b/spec/controllers/projects/security/configuration_controller_spec.rb @@ -13,42 +13,28 @@ RSpec.describe Projects::Security::ConfigurationController do end describe 'GET show' do - context 'when feature flag is disabled' do + context 'when user has guest access' do before do - stub_feature_flags(secure_security_and_compliance_configuration_page_on_ce: false) + project.add_guest(user) end - it 'renders not found' do + it 'denies access' do get :show, params: { namespace_id: project.namespace, project_id: project } - expect(response).to have_gitlab_http_status(:not_found) + expect(response).to have_gitlab_http_status(:forbidden) end end - context 'when feature flag is enabled' do - context 'when user has guest access' do - before do - project.add_guest(user) - end - - it 'denies access' do - get :show, params: { namespace_id: project.namespace, project_id: project } - - expect(response).to have_gitlab_http_status(:forbidden) - end + context 'when user has developer access' do + before do + project.add_developer(user) end - context 'when user has developer access' do - before do - project.add_developer(user) - end - - it 'grants access' do - get :show, params: { namespace_id: project.namespace, project_id: project } + it 'grants access' do + get :show, params: { namespace_id: project.namespace, project_id: project } - expect(response).to have_gitlab_http_status(:ok) - expect(response).to render_template(:show) - end + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template(:show) end end end diff --git a/spec/controllers/projects/snippets_controller_spec.rb b/spec/controllers/projects/snippets_controller_spec.rb index f9221c5a4ef..793ffbbfad9 100644 --- a/spec/controllers/projects/snippets_controller_spec.rb +++ b/spec/controllers/projects/snippets_controller_spec.rb @@ -207,14 +207,14 @@ RSpec.describe Projects::SnippetsController do subject expect(assigns(:snippet)).to eq(project_snippet) - expect(assigns(:blobs)).to eq(project_snippet.blobs) + expect(assigns(:blobs).map(&:name)).to eq(project_snippet.blobs.map(&:name)) expect(response).to have_gitlab_http_status(:ok) end it 'does not show the blobs expanded by default' do subject - expect(project_snippet.blobs.map(&:expanded?)).to be_all(false) + expect(assigns(:blobs).map(&:expanded?)).to be_all(false) end context 'when param expanded is set' do @@ -223,7 +223,7 @@ RSpec.describe Projects::SnippetsController do it 'shows all blobs expanded' do subject - expect(project_snippet.blobs.map(&:expanded?)).to be_all(true) + expect(assigns(:blobs).map(&:expanded?)).to be_all(true) end end end diff --git a/spec/controllers/projects/templates_controller_spec.rb b/spec/controllers/projects/templates_controller_spec.rb index fe282baf769..bd299efb5b5 100644 --- a/spec/controllers/projects/templates_controller_spec.rb +++ b/spec/controllers/projects/templates_controller_spec.rb @@ -160,13 +160,28 @@ RSpec.describe Projects::TemplatesController do end shared_examples 'template names request' do - it 'returns the template names' do - get(:names, params: { namespace_id: project.namespace, template_type: template_type, project_id: project }, format: :json) + context 'when feature flag enabled' do + it 'returns the template names', :aggregate_failures do + get(:names, params: { namespace_id: project.namespace, template_type: template_type, project_id: project }, format: :json) - expect(response).to have_gitlab_http_status(:ok) - expect(json_response.size).to eq(2) - expect(json_response.size).to eq(2) - expect(json_response.map { |x| x.slice('name') }).to match(expected_template_names) + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['Project Templates'].size).to eq(2) + expect(json_response['Project Templates'].map { |x| x.slice('name') }).to match(expected_template_names) + end + end + + context 'when feature flag disabled' do + before do + stub_feature_flags(inherited_issuable_templates: false) + end + + it 'returns the template names', :aggregate_failures do + get(:names, params: { namespace_id: project.namespace, template_type: template_type, project_id: project }, format: :json) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.size).to eq(2) + expect(json_response.map { |x| x.slice('name') }).to match(expected_template_names) + end end it 'fails for user with no access' do diff --git a/spec/controllers/projects/web_ide_schemas_controller_spec.rb b/spec/controllers/projects/web_ide_schemas_controller_spec.rb index fbec941aecc..136edd2f7ad 100644 --- a/spec/controllers/projects/web_ide_schemas_controller_spec.rb +++ b/spec/controllers/projects/web_ide_schemas_controller_spec.rb @@ -53,13 +53,13 @@ RSpec.describe Projects::WebIdeSchemasController do end context 'when an error occurs parsing the schema' do - let(:result) { { status: :error, message: 'Some error occured' } } + let(:result) { { status: :error, message: 'Some error occurred' } } it 'returns 422 with the error' do subject expect(response).to have_gitlab_http_status(:unprocessable_entity) - expect(response.body).to eq('{"status":"error","message":"Some error occured"}') + expect(response.body).to eq('{"status":"error","message":"Some error occurred"}') end end end diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index 1e4ec48b119..554487db8f2 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -221,6 +221,20 @@ RSpec.describe ProjectsController do allow(controller).to receive(:record_experiment_user) end + context 'when user can push to default branch' do + let(:user) { empty_project.owner } + + it 'creates an "view_project_show" experiment tracking event', :snowplow do + allow_next_instance_of(ApplicationExperiment) do |e| + allow(e).to receive(:should_track?).and_return(true) + end + + get :show, params: { namespace_id: empty_project.namespace, id: empty_project } + + expect_snowplow_event(category: 'empty_repo_upload', action: 'view_project_show', context: [{ schema: 'iglu:com.gitlab/gitlab_experiment/jsonschema/0-3-0', data: anything }], property: 'empty') + end + end + User.project_views.keys.each do |project_view| context "with #{project_view} view set" do before do @@ -416,7 +430,8 @@ RSpec.describe ProjectsController do path: 'foo', description: 'bar', namespace_id: user.namespace.id, - visibility_level: Gitlab::VisibilityLevel::PUBLIC + visibility_level: Gitlab::VisibilityLevel::PUBLIC, + initialize_with_readme: 1 } end @@ -425,9 +440,11 @@ RSpec.describe ProjectsController do end it 'tracks a created event for the new_project_readme experiment', :experiment do - expect(experiment(:new_project_readme)).to track(:created, property: 'blank').on_any_instance.with_context( - actor: user - ) + expect(experiment(:new_project_readme)).to track( + :created, + property: 'blank', + value: 1 + ).on_any_instance.with_context(actor: user) post :create, params: { project: project_params } end @@ -1345,6 +1362,14 @@ RSpec.describe ProjectsController do expect(response.body).to eq('This endpoint has been requested too many times. Try again later.') expect(response).to have_gitlab_http_status(:too_many_requests) end + + it 'applies correct scope when throttling' do + expect(Gitlab::ApplicationRateLimiter) + .to receive(:throttled?) + .with(:project_download_export, scope: [user, project]) + + post action, params: { namespace_id: project.namespace, id: project } + end end end end diff --git a/spec/controllers/repositories/git_http_controller_spec.rb b/spec/controllers/repositories/git_http_controller_spec.rb index d21f602f90c..4eede594bb9 100644 --- a/spec/controllers/repositories/git_http_controller_spec.rb +++ b/spec/controllers/repositories/git_http_controller_spec.rb @@ -54,14 +54,17 @@ RSpec.describe Repositories::GitHttpController do }.from(0).to(1) end - it_behaves_like 'records an onboarding progress action', :git_read do - let(:namespace) { project.namespace } - - subject { send_request } + describe 'recording the onboarding progress', :sidekiq_inline do + let_it_be(:namespace) { project.namespace } before do - stub_feature_flags(disable_git_http_fetch_writes: false) + OnboardingProgress.onboard(namespace) + send_request end + + subject { OnboardingProgress.completed?(namespace, :git_pull) } + + it { is_expected.to be(true) } end context 'when disable_git_http_fetch_writes is enabled' do @@ -75,12 +78,6 @@ RSpec.describe Repositories::GitHttpController do send_request end - - it 'does not record onboarding progress' do - expect(OnboardingProgressService).not_to receive(:new) - - send_request - end end end end diff --git a/spec/controllers/root_controller_spec.rb b/spec/controllers/root_controller_spec.rb index 85f9ea66c5f..49841aa61d7 100644 --- a/spec/controllers/root_controller_spec.rb +++ b/spec/controllers/root_controller_spec.rb @@ -68,6 +68,18 @@ RSpec.describe RootController do end end + context 'who has customized their dashboard setting for followed user activities' do + before do + user.dashboard = 'followed_user_activity' + end + + it 'redirects to the activity list' do + get :index + + expect(response).to redirect_to activity_dashboard_path(filter: 'followed') + end + end + context 'who has customized their dashboard setting for groups' do before do user.dashboard = 'groups' @@ -123,11 +135,7 @@ RSpec.describe RootController do expect(response).to render_template 'dashboard/projects/index' end - context 'when experiment is enabled' do - before do - stub_experiment_for_subject(customize_homepage: true) - end - + context 'when customize_homepage is enabled' do it 'renders the default dashboard' do get :index @@ -135,9 +143,9 @@ RSpec.describe RootController do end end - context 'when experiment not enabled' do + context 'when customize_homepage is not enabled' do before do - stub_experiment(customize_homepage: false) + stub_feature_flags(customize_homepage: false) end it 'renders the default dashboard' do diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb index 95cea10f0d0..32ac83847aa 100644 --- a/spec/controllers/search_controller_spec.rb +++ b/spec/controllers/search_controller_spec.rb @@ -252,6 +252,14 @@ RSpec.describe SearchController do get :count, params: { search: 'hello' } end.to raise_error(ActionController::ParameterMissing) end + + it 'sets private cache control headers' do + get :count, params: { search: 'hello', scope: 'projects' } + + expect(response).to have_gitlab_http_status(:ok) + + expect(response.headers['Cache-Control']).to include('max-age=60, private') + end end describe 'GET #autocomplete' do @@ -261,23 +269,29 @@ RSpec.describe SearchController do describe '#append_info_to_payload' do it 'appends search metadata for logging' do - last_payload = nil - original_append_info_to_payload = controller.method(:append_info_to_payload) - - expect(controller).to receive(:append_info_to_payload) do |payload| - original_append_info_to_payload.call(payload) - last_payload = payload + expect(controller).to receive(:append_info_to_payload).and_wrap_original do |method, payload| + method.call(payload) + + expect(payload[:metadata]['meta.search.group_id']).to eq('123') + expect(payload[:metadata]['meta.search.project_id']).to eq('456') + expect(payload[:metadata]).not_to have_key('meta.search.search') + expect(payload[:metadata]['meta.search.scope']).to eq('issues') + expect(payload[:metadata]['meta.search.force_search_results']).to eq('true') + expect(payload[:metadata]['meta.search.filters.confidential']).to eq('true') + expect(payload[:metadata]['meta.search.filters.state']).to eq('true') end get :show, params: { scope: 'issues', search: 'hello world', group_id: '123', project_id: '456', confidential: true, state: true, force_search_results: true } + end + + it 'appends the default scope in meta.search.scope' do + expect(controller).to receive(:append_info_to_payload).and_wrap_original do |method, payload| + method.call(payload) + + expect(payload[:metadata]['meta.search.scope']).to eq('projects') + end - expect(last_payload[:metadata]['meta.search.group_id']).to eq('123') - expect(last_payload[:metadata]['meta.search.project_id']).to eq('456') - expect(last_payload[:metadata]).not_to have_key('meta.search.search') - expect(last_payload[:metadata]['meta.search.scope']).to eq('issues') - expect(last_payload[:metadata]['meta.search.force_search_results']).to eq('true') - expect(last_payload[:metadata]['meta.search.filters.confidential']).to eq('true') - expect(last_payload[:metadata]['meta.search.filters.state']).to eq('true') + get :show, params: { search: 'hello world', group_id: '123', project_id: '456' } end end end diff --git a/spec/controllers/snippets/notes_controller_spec.rb b/spec/controllers/snippets/notes_controller_spec.rb index 487635169fc..558e68fbb8f 100644 --- a/spec/controllers/snippets/notes_controller_spec.rb +++ b/spec/controllers/snippets/notes_controller_spec.rb @@ -141,6 +141,11 @@ RSpec.describe Snippets::NotesController do it 'creates the note' do expect { post :create, params: request_params }.to change { Note.count }.by(1) end + + it_behaves_like 'request exceeding rate limit', :clean_gitlab_redis_cache do + let(:params) { request_params } + let(:request_full_path) { snippet_notes_path(public_snippet) } + end end context 'when a snippet is internal' do @@ -164,6 +169,11 @@ RSpec.describe Snippets::NotesController do it 'creates the note' do expect { post :create, params: request_params }.to change { Note.count }.by(1) end + + it_behaves_like 'request exceeding rate limit', :clean_gitlab_redis_cache do + let(:params) { request_params } + let(:request_full_path) { snippet_notes_path(internal_snippet) } + end end context 'when a snippet is private' do @@ -228,6 +238,12 @@ RSpec.describe Snippets::NotesController do it 'creates the note' do expect { post :create, params: request_params }.to change { Note.count }.by(1) end + + it_behaves_like 'request exceeding rate limit', :clean_gitlab_redis_cache do + let(:params) { request_params } + let(:request_full_path) { snippet_notes_path(private_snippet) } + let(:user) { private_snippet.author } + end end end end diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb index b2c77a06f19..d292ba60a12 100644 --- a/spec/db/schema_spec.rb +++ b/spec/db/schema_spec.rb @@ -87,7 +87,8 @@ RSpec.describe 'Database schema' do users_star_projects: %w[user_id], vulnerability_identifiers: %w[external_id], vulnerability_scanners: %w[external_id], - web_hooks: %w[group_id] + web_hooks: %w[group_id], + web_hook_logs_part_0c5294f417: %w[web_hook_id] }.with_indifferent_access.freeze context 'for table' do @@ -102,7 +103,12 @@ RSpec.describe 'Database schema' do context 'all foreign keys' do # for index to be effective, the FK constraint has to be at first place it 'are indexed' do - first_indexed_column = indexes.map(&:columns).map(&:first) + first_indexed_column = indexes.map(&:columns).map do |columns| + # In cases of complex composite indexes, a string is returned eg: + # "lower((extern_uid)::text), group_id" + columns = columns.split(',') if columns.is_a?(String) + columns.first.chomp + end foreign_keys_columns = foreign_keys.map(&:column) # Add the primary key column to the list of indexed columns because diff --git a/spec/deprecation_toolkit_env.rb b/spec/deprecation_toolkit_env.rb index 10eaaf13aaa..f4ead6d5f01 100644 --- a/spec/deprecation_toolkit_env.rb +++ b/spec/deprecation_toolkit_env.rb @@ -61,6 +61,7 @@ module DeprecationToolkitEnv batch-loader-1.4.0/lib/batch_loader/graphql.rb carrierwave-1.3.1/lib/carrierwave/sanitized_file.rb activerecord-6.0.3.4/lib/active_record/relation.rb + selenium-webdriver-3.142.7/lib/selenium/webdriver/firefox/driver.rb asciidoctor-2.0.12/lib/asciidoctor/extensions.rb ] end diff --git a/spec/experiments/application_experiment/cache_spec.rb b/spec/experiments/application_experiment/cache_spec.rb deleted file mode 100644 index 4caa91e6ac4..00000000000 --- a/spec/experiments/application_experiment/cache_spec.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe ApplicationExperiment::Cache do - let(:key_name) { 'experiment_name' } - let(:field_name) { 'abc123' } - let(:key_field) { [key_name, field_name].join(':') } - let(:shared_state) { Gitlab::Redis::SharedState } - - around do |example| - shared_state.with { |r| r.del(key_name) } - example.run - shared_state.with { |r| r.del(key_name) } - end - - it "allows reading, writing and deleting", :aggregate_failures do - # we test them all together because they are largely interdependent - - expect(subject.read(key_field)).to be_nil - expect(shared_state.with { |r| r.hget(key_name, field_name) }).to be_nil - - subject.write(key_field, 'value') - - expect(subject.read(key_field)).to eq('value') - expect(shared_state.with { |r| r.hget(key_name, field_name) }).to eq('value') - - subject.delete(key_field) - - expect(subject.read(key_field)).to be_nil - expect(shared_state.with { |r| r.hget(key_name, field_name) }).to be_nil - end - - it "handles the fetch with a block behavior (which is read/write)" do - expect(subject.fetch(key_field) { 'value1' }).to eq('value1') # rubocop:disable Style/RedundantFetchBlock - expect(subject.fetch(key_field) { 'value2' }).to eq('value1') # rubocop:disable Style/RedundantFetchBlock - end - - it "can clear a whole experiment cache key" do - subject.write(key_field, 'value') - subject.clear(key: key_field) - - expect(shared_state.with { |r| r.get(key_name) }).to be_nil - end - - it "doesn't allow clearing a key from the cache that's not a hash (definitely not an experiment)" do - shared_state.with { |r| r.set(key_name, 'value') } - - expect { subject.clear(key: key_name) }.to raise_error( - ArgumentError, - 'invalid call to clear a non-hash cache key' - ) - end -end diff --git a/spec/experiments/application_experiment_spec.rb b/spec/experiments/application_experiment_spec.rb index 501d344e920..2481ee5a806 100644 --- a/spec/experiments/application_experiment_spec.rb +++ b/spec/experiments/application_experiment_spec.rb @@ -58,30 +58,38 @@ RSpec.describe ApplicationExperiment, :experiment do end describe "publishing results" do + it "doesn't track or push data to the client if we shouldn't track", :snowplow do + allow(subject).to receive(:should_track?).and_return(false) + expect(Gon).not_to receive(:push) + + subject.publish(:action) + + expect_no_snowplow_event + end + it "tracks the assignment" do expect(subject).to receive(:track).with(:assignment) - subject.publish(nil) + subject.publish end - it "pushes the experiment knowledge into the client using Gon.global" do - expect(Gon.global).to receive(:push).with( - { - experiment: { - 'namespaced/stub' => { # string key because it can be namespaced - experiment: 'namespaced/stub', - key: '86208ac54ca798e11f127e8b23ec396a', - variant: 'control' - } - } - }, - true - ) + it "pushes the experiment knowledge into the client using Gon" do + expect(Gon).to receive(:push).with({ experiment: { 'namespaced/stub' => subject.signature } }, true) + + subject.publish + end - subject.publish(nil) + it "handles when Gon raises exceptions (like when it can't be pushed into)" do + expect(Gon).to receive(:push).and_raise(NoMethodError) + + expect { subject.publish }.not_to raise_error end end + it "can exclude from within the block" do + expect(described_class.new('namespaced/stub') { |e| e.exclude! }).to be_excluded + end + describe "tracking events", :snowplow do it "doesn't track if we shouldn't track" do allow(subject).to receive(:should_track?).and_return(false) @@ -115,91 +123,36 @@ RSpec.describe ApplicationExperiment, :experiment do end describe "variant resolution" do - context "when using the default feature flag percentage rollout" do - it "uses the default value as specified in the yaml" do - expect(Feature).to receive(:enabled?).with('namespaced_stub', subject, type: :experiment, default_enabled: :yaml) - - expect(subject.variant.name).to eq('control') - end + it "uses the default value as specified in the yaml" do + expect(Feature).to receive(:enabled?).with('namespaced_stub', subject, type: :experiment, default_enabled: :yaml) - it "returns nil when not rolled out" do - stub_feature_flags(namespaced_stub: false) - - expect(subject.variant.name).to eq('control') - end - - context "when rolled out to 100%" do - it "returns the first variant name" do - subject.try(:variant1) {} - subject.try(:variant2) {} - - expect(subject.variant.name).to eq('variant1') - end - end + expect(subject.variant.name).to eq('control') end - context "when using the round_robin strategy", :clean_gitlab_redis_shared_state do - context "when variants aren't supplied" do - subject :inheriting_class do - Class.new(described_class) do - def rollout_strategy - :round_robin - end - end.new('namespaced/stub') - end - - it "raises an error" do - expect { inheriting_class.variants }.to raise_error(NotImplementedError) - end + context "when rolled out to 100%" do + before do + stub_feature_flags(namespaced_stub: true) end - context "when variants are supplied" do - let(:inheriting_class) do - Class.new(described_class) do - def rollout_strategy - :round_robin - end - - def variants - %i[variant1 variant2 control] - end - end - end - - it "proves out round robin in variant selection", :aggregate_failures do - instance_1 = inheriting_class.new('namespaced/stub') - allow(instance_1).to receive(:enabled?).and_return(true) - instance_2 = inheriting_class.new('namespaced/stub') - allow(instance_2).to receive(:enabled?).and_return(true) - instance_3 = inheriting_class.new('namespaced/stub') - allow(instance_3).to receive(:enabled?).and_return(true) - - instance_1.try {} - - expect(instance_1.variant.name).to eq('variant2') + it "returns the first variant name" do + subject.try(:variant1) {} + subject.try(:variant2) {} - instance_2.try {} - - expect(instance_2.variant.name).to eq('control') - - instance_3.try {} - - expect(instance_3.variant.name).to eq('variant1') - end + expect(subject.variant.name).to eq('variant1') end end end context "when caching" do - let(:cache) { ApplicationExperiment::Cache.new } + let(:cache) { Gitlab::Experiment::Configuration.cache } before do + allow(Gitlab::Experiment::Configuration).to receive(:cache).and_call_original + cache.clear(key: subject.name) subject.use { } # setup the control subject.try { } # setup the candidate - - allow(Gitlab::Experiment::Configuration).to receive(:cache).and_return(cache) end it "caches the variant determined by the variant resolver" do @@ -207,7 +160,7 @@ RSpec.describe ApplicationExperiment, :experiment do subject.run - expect(cache.read(subject.cache_key)).to eq('candidate') + expect(subject.cache.read).to eq('candidate') end it "doesn't cache a variant if we don't explicitly provide one" do @@ -222,7 +175,7 @@ RSpec.describe ApplicationExperiment, :experiment do subject.run - expect(cache.read(subject.cache_key)).to be_nil + expect(subject.cache.read).to be_nil end it "caches a control variant if we assign it specifically" do @@ -232,7 +185,26 @@ RSpec.describe ApplicationExperiment, :experiment do # write code that would specify a different variant. subject.run(:control) - expect(cache.read(subject.cache_key)).to eq('control') + expect(subject.cache.read).to eq('control') + end + + context "arbitrary attributes" do + before do + subject.cache.store.clear(key: subject.name + '_attrs') + end + + it "sets and gets attributes about an experiment" do + subject.cache.attr_set(:foo, :bar) + + expect(subject.cache.attr_get(:foo)).to eq('bar') + end + + it "increments a value for an experiment" do + expect(subject.cache.attr_get(:foo)).to be_nil + + expect(subject.cache.attr_inc(:foo)).to eq(1) + expect(subject.cache.attr_inc(:foo)).to eq(2) + end end end end diff --git a/spec/experiments/members/invite_email_experiment_spec.rb b/spec/experiments/members/invite_email_experiment_spec.rb index 4376c021385..539230e39b9 100644 --- a/spec/experiments/members/invite_email_experiment_spec.rb +++ b/spec/experiments/members/invite_email_experiment_spec.rb @@ -3,26 +3,14 @@ require 'spec_helper' RSpec.describe Members::InviteEmailExperiment do - subject :invite_email do - experiment('members/invite_email', actor: double('Member', created_by: double('User', avatar_url: '_avatar_url_'))) - end + subject(:invite_email) { experiment('members/invite_email', **context) } + + let(:context) { { actor: double('Member', created_by: double('User', avatar_url: '_avatar_url_')) } } before do allow(invite_email).to receive(:enabled?).and_return(true) end - describe "#rollout_strategy" do - it "resolves to round_robin" do - expect(invite_email.rollout_strategy).to eq(:round_robin) - end - end - - describe "#variants" do - it "has all the expected variants" do - expect(invite_email.variants).to match(%i[avatar permission_info control]) - end - end - describe "exclusions", :experiment do it "excludes when created by is nil" do expect(experiment('members/invite_email')).to exclude(actor: double(created_by: nil)) @@ -34,4 +22,27 @@ RSpec.describe Members::InviteEmailExperiment do expect(experiment('members/invite_email')).to exclude(actor: member_without_avatar_url) end end + + describe "variant resolution", :clean_gitlab_redis_shared_state do + it "proves out round robin in variant selection", :aggregate_failures do + instance_1 = described_class.new('members/invite_email', **context) + allow(instance_1).to receive(:enabled?).and_return(true) + instance_2 = described_class.new('members/invite_email', **context) + allow(instance_2).to receive(:enabled?).and_return(true) + instance_3 = described_class.new('members/invite_email', **context) + allow(instance_3).to receive(:enabled?).and_return(true) + + instance_1.try { } + + expect(instance_1.variant.name).to eq('permission_info') + + instance_2.try { } + + expect(instance_2.variant.name).to eq('control') + + instance_3.try { } + + expect(instance_3.variant.name).to eq('avatar') + end + end end diff --git a/spec/factories/alert_management/alerts.rb b/spec/factories/alert_management/alerts.rb index e36e4c38013..ee1225b9542 100644 --- a/spec/factories/alert_management/alerts.rb +++ b/spec/factories/alert_management/alerts.rb @@ -47,10 +47,6 @@ FactoryBot.define do hosts { [FFaker::Internet.ip_v4_address] } end - trait :with_ended_at do - ended_at { Time.current } - end - trait :without_ended_at do ended_at { nil } end @@ -67,7 +63,7 @@ FactoryBot.define do trait :resolved do status { AlertManagement::Alert.status_value(:resolved) } - with_ended_at + ended_at { Time.current } end trait :ignored do diff --git a/spec/factories/analytics/instance_statistics/measurement.rb b/spec/factories/analytics/usage_trends/measurement.rb index f9398cd3061..ec80174e967 100644 --- a/spec/factories/analytics/instance_statistics/measurement.rb +++ b/spec/factories/analytics/usage_trends/measurement.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :instance_statistics_measurement, class: 'Analytics::InstanceStatistics::Measurement' do + factory :usage_trends_measurement, class: 'Analytics::UsageTrends::Measurement' do recorded_at { Time.now } identifier { :projects } count { 1_000 } diff --git a/spec/factories/bulk_import/trackers.rb b/spec/factories/bulk_import/trackers.rb index 7a1fa0849fc..03af5b41e0f 100644 --- a/spec/factories/bulk_import/trackers.rb +++ b/spec/factories/bulk_import/trackers.rb @@ -4,6 +4,7 @@ FactoryBot.define do factory :bulk_import_tracker, class: 'BulkImports::Tracker' do association :entity, factory: :bulk_import_entity + stage { 0 } relation { :relation } has_next_page { false } end diff --git a/spec/factories/ci/build_report_results.rb b/spec/factories/ci/build_report_results.rb index 0685c0e5554..7d716d6d81a 100644 --- a/spec/factories/ci/build_report_results.rb +++ b/spec/factories/ci/build_report_results.rb @@ -4,10 +4,15 @@ FactoryBot.define do factory :ci_build_report_result, class: 'Ci::BuildReportResult' do build factory: :ci_build project factory: :project + + transient do + test_suite_name { "rspec" } + end + data do { tests: { - name: "rspec", + name: test_suite_name, duration: 0.42, failed: 0, errored: 2, @@ -21,7 +26,7 @@ FactoryBot.define do data do { tests: { - name: "rspec", + name: test_suite_name, duration: 0.42, failed: 0, errored: 0, @@ -31,5 +36,25 @@ FactoryBot.define do } end end + + trait :with_junit_suite_error do + transient do + test_suite_error { "some error" } + end + + data do + { + tests: { + name: test_suite_name, + duration: 0.42, + failed: 0, + errored: 0, + skipped: 0, + success: 2, + suite_error: test_suite_error + } + } + end + end end end diff --git a/spec/factories/ci/build_trace_chunks.rb b/spec/factories/ci/build_trace_chunks.rb index d996b41b648..115eb32111c 100644 --- a/spec/factories/ci/build_trace_chunks.rb +++ b/spec/factories/ci/build_trace_chunks.rb @@ -3,7 +3,7 @@ FactoryBot.define do factory :ci_build_trace_chunk, class: 'Ci::BuildTraceChunk' do build factory: :ci_build - chunk_index { 0 } + chunk_index { generate(:iid) } data_store { :redis } trait :redis_with_data do diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index c4f9a4ce82b..886be520668 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -475,7 +475,7 @@ FactoryBot.define do trait :license_scanning do options do { - artifacts: { reports: { license_management: 'gl-license-scanning-report.json' } } + artifacts: { reports: { license_scanning: 'gl-license-scanning-report.json' } } } end end diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index e0d7ad3c133..87b9a6c0e23 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -40,6 +40,10 @@ FactoryBot.define do end end + trait :created do + status { :created } + end + factory :ci_pipeline do transient { ci_ref_presence { true } } @@ -53,10 +57,6 @@ FactoryBot.define do failure_reason { :config_error } end - trait :created do - status { :created } - end - trait :preparing do status { :preparing } end diff --git a/spec/factories/clusters/agent_tokens.rb b/spec/factories/clusters/agent_tokens.rb index 6f92f2217b3..c49d197c3cd 100644 --- a/spec/factories/clusters/agent_tokens.rb +++ b/spec/factories/clusters/agent_tokens.rb @@ -5,5 +5,7 @@ FactoryBot.define do association :agent, factory: :cluster_agent token_encrypted { Gitlab::CryptoHelper.aes256_gcm_encrypt(SecureRandom.hex(50)) } + + sequence(:name) { |n| "agent-token-#{n}" } end end diff --git a/spec/factories/custom_emoji.rb b/spec/factories/custom_emoji.rb index ba1ae11c18d..88e50eafa7c 100644 --- a/spec/factories/custom_emoji.rb +++ b/spec/factories/custom_emoji.rb @@ -6,5 +6,6 @@ FactoryBot.define do namespace group file { 'https://gitlab.com/images/partyparrot.png' } + creator { namespace.owner } end end diff --git a/spec/factories/dependency_proxy.rb b/spec/factories/dependency_proxy.rb index de95df19876..94a7986a8fa 100644 --- a/spec/factories/dependency_proxy.rb +++ b/spec/factories/dependency_proxy.rb @@ -10,7 +10,8 @@ FactoryBot.define do factory :dependency_proxy_manifest, class: 'DependencyProxy::Manifest' do group file { fixture_file_upload('spec/fixtures/dependency_proxy/manifest') } - digest { 'sha256:5ab5a6872b264fe4fd35d63991b9b7d8425f4bc79e7cf4d563c10956581170c9' } + digest { 'sha256:d0710affa17fad5f466a70159cc458227bd25d4afb39514ef662ead3e6c99515' } file_name { 'alpine:latest.json' } + content_type { 'application/vnd.docker.distribution.manifest.v2+json' } end end diff --git a/spec/factories/design_management/versions.rb b/spec/factories/design_management/versions.rb index 0233a3b567d..247a385bd0e 100644 --- a/spec/factories/design_management/versions.rb +++ b/spec/factories/design_management/versions.rb @@ -13,11 +13,6 @@ FactoryBot.define do deleted_designs { [] } end - # Warning: this will intentionally result in an invalid version! - trait :empty do - designs_count { 0 } - end - trait :importing do issue { nil } diff --git a/spec/factories/environments.rb b/spec/factories/environments.rb index 050cb8f8e6c..072a5f1f402 100644 --- a/spec/factories/environments.rb +++ b/spec/factories/environments.rb @@ -15,7 +15,25 @@ FactoryBot.define do state { :stopped } end + trait :production do + tier { :production } + end + + trait :staging do + tier { :staging } + end + + trait :testing do + tier { :testing } + end + + trait :development do + tier { :development } + end + trait :with_review_app do |environment| + sequence(:name) { |n| "review/#{n}" } + transient do ref { 'master' } end diff --git a/spec/factories/gitlab/database/background_migration/batched_jobs.rb b/spec/factories/gitlab/database/background_migration/batched_jobs.rb new file mode 100644 index 00000000000..52bc04447da --- /dev/null +++ b/spec/factories/gitlab/database/background_migration/batched_jobs.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :batched_background_migration_job, class: '::Gitlab::Database::BackgroundMigration::BatchedJob' do + batched_migration factory: :batched_background_migration + + min_value { 1 } + max_value { 10 } + batch_size { 5 } + sub_batch_size { 1 } + end +end diff --git a/spec/factories/gitlab/database/background_migration/batched_migrations.rb b/spec/factories/gitlab/database/background_migration/batched_migrations.rb new file mode 100644 index 00000000000..b45f6ff037b --- /dev/null +++ b/spec/factories/gitlab/database/background_migration/batched_migrations.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :batched_background_migration, class: '::Gitlab::Database::BackgroundMigration::BatchedMigration' do + max_value { 10 } + batch_size { 5 } + sub_batch_size { 1 } + interval { 2.minutes } + job_class_name { 'CopyColumnUsingBackgroundMigrationJob' } + table_name { :events } + column_name { :id } + end +end diff --git a/spec/factories/go_module_commits.rb b/spec/factories/go_module_commits.rb index e42ef6696d1..514a5559344 100644 --- a/spec/factories/go_module_commits.rb +++ b/spec/factories/go_module_commits.rb @@ -7,7 +7,12 @@ FactoryBot.define do transient do files { { 'foo.txt' => 'content' } } message { 'Message' } + # rubocop: disable FactoryBot/InlineAssociation + # We need a persisted project so we can create commits and tags + # in `commit` otherwise linting this factory with `build` strategy + # will fail. project { create(:project, :repository) } + # rubocop: enable FactoryBot/InlineAssociation service do Files::MultiService.new( @@ -44,14 +49,13 @@ FactoryBot.define do trait :files do transient do - files { raise ArgumentError.new("files is required") } message { 'Add files' } end end trait :package do transient do - path { raise ArgumentError.new("path is required") } + path { 'pkg' } message { 'Add package' } files { { "#{path}/b.go" => "package b\nfunc Bye() { println(\"Goodbye world!\") }\n" } } end @@ -64,7 +68,7 @@ FactoryBot.define do host_prefix { "#{::Gitlab.config.gitlab.host}/#{project.path_with_namespace}" } url { name ? "#{host_prefix}/#{name}" : host_prefix } - path { name.to_s + '/' } + path { "#{name}/" } files do { diff --git a/spec/factories/go_module_versions.rb b/spec/factories/go_module_versions.rb index b0a96197350..145e6c95921 100644 --- a/spec/factories/go_module_versions.rb +++ b/spec/factories/go_module_versions.rb @@ -8,12 +8,12 @@ FactoryBot.define do p = attributes[:params] s = Packages::SemVer.parse(p.semver, prefixed: true) - raise ArgumentError.new("invalid sematic version: '#{p.semver}''") if !s && p.semver + raise ArgumentError, "invalid sematic version: '#{p.semver}'" if !s && p.semver new(p.mod, p.type, p.commit, name: p.name, semver: s, ref: p.ref) end - mod { create :go_module } + mod { association(:go_module) } type { :commit } commit { mod.project.repository.head_commit } name { nil } @@ -33,45 +33,11 @@ FactoryBot.define do mod.project.repository.tags .filter { |t| Packages::SemVer.match?(t.name, prefixed: true) } .map { |t| Packages::SemVer.parse(t.name, prefixed: true) } - .max { |a, b| "#{a}" <=> "#{b}" } + .max_by(&:to_s) .to_s end params { OpenStruct.new(mod: mod, type: :ref, commit: commit, semver: name, ref: ref) } end - - trait :pseudo do - transient do - prefix do - # This provides a sane default value, but in reality the caller should - # specify `prefix:` - - # This does not take into account that `commit` may be before the - # latest tag. - - # Find 'latest' semver tag (does not actually use semver precedence rules) - v = mod.project.repository.tags - .filter { |t| Packages::SemVer.match?(t.name, prefixed: true) } - .map { |t| Packages::SemVer.parse(t.name, prefixed: true) } - .max { |a, b| "#{a}" <=> "#{b}" } - - # Default if no semver tags exist - next 'v0.0.0' unless v - - # Valid pseudo-versions are: - # vX.0.0-yyyymmddhhmmss-sha1337beef0, when no earlier tagged commit exists for X - # vX.Y.Z-pre.0.yyyymmddhhmmss-sha1337beef0, when most recent prior tag is vX.Y.Z-pre - # vX.Y.(Z+1)-0.yyyymmddhhmmss-sha1337beef0, when most recent prior tag is vX.Y.Z - - v = v.with(patch: v.patch + 1) unless v.prerelease - "#{v}.0" - end - end - - type { :pseudo } - name { "#{prefix}#{commit.committed_date.strftime('%Y%m%d%H%M%S')}-#{commit.sha[0..11]}" } - - params { OpenStruct.new(mod: mod, type: :pseudo, commit: commit, name: name, semver: name) } - end end end diff --git a/spec/factories/go_modules.rb b/spec/factories/go_modules.rb index fdbacf48d3b..ca7184a9194 100644 --- a/spec/factories/go_modules.rb +++ b/spec/factories/go_modules.rb @@ -5,7 +5,7 @@ FactoryBot.define do initialize_with { new(attributes[:project], attributes[:name], attributes[:path]) } skip_create - project { create :project, :repository } + project { association(:project, :repository) } path { '' } name { "#{Settings.build_gitlab_go_url}/#{project.full_path}#{path.empty? ? '' : '/'}#{path}" } diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb index 17db69e4699..5d232a9d09a 100644 --- a/spec/factories/groups.rb +++ b/spec/factories/groups.rb @@ -15,7 +15,7 @@ FactoryBot.define do raise "Don't set owner for groups, use `group.add_owner(user)` instead" end - create(:namespace_settings, namespace: group) + create(:namespace_settings, namespace: group) unless group.namespace_settings end trait :public do @@ -61,5 +61,35 @@ FactoryBot.define do trait :allow_descendants_override_disabled_shared_runners do allow_descendants_override_disabled_shared_runners { true } end + + # Construct a hierarchy underneath the group. + # Each group will have `children` amount of children, + # and `depth` levels of descendants. + trait :with_hierarchy do + transient do + children { 4 } + depth { 4 } + end + + after(:create) do |group, evaluator| + def create_graph(parent: nil, children: 4, depth: 4) + return unless depth > 1 + + children.times do + factory_name = parent.model_name.singular + child = FactoryBot.create(factory_name, parent: parent) + create_graph(parent: child, children: children, depth: depth - 1) + end + + parent + end + + create_graph( + parent: group, + children: evaluator.children, + depth: evaluator.depth + ) + end + end end end diff --git a/spec/factories/iterations.rb b/spec/factories/iterations.rb deleted file mode 100644 index bd61cd469af..00000000000 --- a/spec/factories/iterations.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -FactoryBot.define do - sequence(:sequential_date) do |n| - n.days.from_now - end - - factory :iteration do - title - start_date { generate(:sequential_date) } - due_date { generate(:sequential_date) } - - transient do - project { nil } - group { nil } - project_id { nil } - group_id { nil } - resource_parent { nil } - end - - trait :upcoming do - state_enum { Iteration::STATE_ENUM_MAP[:upcoming] } - end - - trait :started do - state_enum { Iteration::STATE_ENUM_MAP[:started] } - end - - trait :closed do - state_enum { Iteration::STATE_ENUM_MAP[:closed] } - end - - trait(:skip_future_date_validation) do - after(:stub, :build) do |iteration| - iteration.skip_future_date_validation = true - end - end - - trait(:skip_project_validation) do - after(:stub, :build) do |iteration| - iteration.skip_project_validation = true - end - end - - after(:build, :stub) do |iteration, evaluator| - if evaluator.group - iteration.group = evaluator.group - elsif evaluator.group_id - iteration.group_id = evaluator.group_id - elsif evaluator.project - iteration.project = evaluator.project - elsif evaluator.project_id - iteration.project_id = evaluator.project_id - elsif evaluator.resource_parent - id = evaluator.resource_parent.id - evaluator.resource_parent.is_a?(Group) ? evaluator.group_id = id : evaluator.project_id = id - else - iteration.group = create(:group) - end - end - - factory :upcoming_iteration, traits: [:upcoming] - factory :started_iteration, traits: [:started] - factory :closed_iteration, traits: [:closed] - end -end diff --git a/spec/factories/namespaces.rb b/spec/factories/namespaces.rb index 0ec977b8234..f4b57369678 100644 --- a/spec/factories/namespaces.rb +++ b/spec/factories/namespaces.rb @@ -23,44 +23,20 @@ FactoryBot.define do end trait :with_aggregation_schedule do - association :aggregation_schedule, factory: :namespace_aggregation_schedules + after(:create) do |namespace| + create(:namespace_aggregation_schedules, namespace: namespace) + end end trait :with_root_storage_statistics do - association :root_storage_statistics, factory: :namespace_root_storage_statistics + after(:create) do |namespace| + create(:namespace_root_storage_statistics, namespace: namespace) + end end trait :with_namespace_settings do - association :namespace_settings, factory: :namespace_settings - end - - # Construct a hierarchy underneath the namespace. - # Each namespace will have `children` amount of children, - # and `depth` levels of descendants. - trait :with_hierarchy do - transient do - children { 4 } - depth { 4 } - end - - after(:create) do |namespace, evaluator| - def create_graph(parent: nil, children: 4, depth: 4) - return unless depth > 1 - - children.times do - factory_name = parent.model_name.singular - child = FactoryBot.create(factory_name, parent: parent) - create_graph(parent: child, children: children, depth: depth - 1) - end - - parent - end - - create_graph( - parent: namespace, - children: evaluator.children, - depth: evaluator.depth - ) + after(:create) do |namespace| + create(:namespace_settings, namespace: namespace) end end diff --git a/spec/factories/packages.rb b/spec/factories/packages.rb index 2c64abefb01..882bac1daa9 100644 --- a/spec/factories/packages.rb +++ b/spec/factories/packages.rb @@ -277,6 +277,10 @@ FactoryBot.define do factory :packages_dependency, class: 'Packages::Dependency' do sequence(:name) { |n| "@test/package-#{n}"} sequence(:version_pattern) { |n| "~6.2.#{n}" } + + trait(:rubygems) do + sequence(:name) { |n| "gem-dependency-#{n}"} + end end factory :packages_dependency_link, class: 'Packages::DependencyLink' do @@ -289,6 +293,11 @@ FactoryBot.define do link.nuget_metadatum = build(:nuget_dependency_link_metadatum) end end + + trait(:rubygems) do + package { association(:rubygems_package) } + dependency { association(:packages_dependency, :rubygems) } + end end factory :nuget_dependency_link_metadatum, class: 'Packages::Nuget::DependencyLinkMetadatum' do diff --git a/spec/factories/project_repository_storage_moves.rb b/spec/factories/project_repository_storage_moves.rb index 5df2b7c32d6..018b6cde32b 100644 --- a/spec/factories/project_repository_storage_moves.rb +++ b/spec/factories/project_repository_storage_moves.rb @@ -1,29 +1,29 @@ # frozen_string_literal: true FactoryBot.define do - factory :project_repository_storage_move, class: 'ProjectRepositoryStorageMove' do + factory :project_repository_storage_move, class: 'Projects::RepositoryStorageMove' do container { association(:project) } source_storage_name { 'default' } trait :scheduled do - state { ProjectRepositoryStorageMove.state_machines[:state].states[:scheduled].value } + state { Projects::RepositoryStorageMove.state_machines[:state].states[:scheduled].value } end trait :started do - state { ProjectRepositoryStorageMove.state_machines[:state].states[:started].value } + state { Projects::RepositoryStorageMove.state_machines[:state].states[:started].value } end trait :replicated do - state { ProjectRepositoryStorageMove.state_machines[:state].states[:replicated].value } + state { Projects::RepositoryStorageMove.state_machines[:state].states[:replicated].value } end trait :finished do - state { ProjectRepositoryStorageMove.state_machines[:state].states[:finished].value } + state { Projects::RepositoryStorageMove.state_machines[:state].states[:finished].value } end trait :failed do - state { ProjectRepositoryStorageMove.state_machines[:state].states[:failed].value } + state { Projects::RepositoryStorageMove.state_machines[:state].states[:failed].value } end end end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index e8e0362fc62..80392a2fece 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -194,7 +194,7 @@ FactoryBot.define do filename, content, message: "Automatically created file #{filename}", - branch_name: 'master' + branch_name: project.default_branch_or_master ) end end diff --git a/spec/factories/prometheus_alert_event.rb b/spec/factories/prometheus_alert_event.rb index 281fbacabe2..7771a8d5cb7 100644 --- a/spec/factories/prometheus_alert_event.rb +++ b/spec/factories/prometheus_alert_event.rb @@ -13,10 +13,5 @@ FactoryBot.define do ended_at { Time.now } payload_key { nil } end - - trait :none do - status { nil } - started_at { nil } - end end end diff --git a/spec/factories/self_managed_prometheus_alert_event.rb b/spec/factories/self_managed_prometheus_alert_event.rb index 238942e2c46..3a48aba5f54 100644 --- a/spec/factories/self_managed_prometheus_alert_event.rb +++ b/spec/factories/self_managed_prometheus_alert_event.rb @@ -8,16 +8,5 @@ FactoryBot.define do title { 'alert' } query_expression { 'vector(2)' } started_at { Time.now } - - trait :resolved do - status { SelfManagedPrometheusAlertEvent.status_value_for(:resolved) } - ended_at { Time.now } - payload_key { nil } - end - - trait :none do - status { nil } - started_at { nil } - end end end diff --git a/spec/factories/snippet_repository_storage_moves.rb b/spec/factories/snippet_repository_storage_moves.rb index ed65dc5374f..dd82ec5cfcb 100644 --- a/spec/factories/snippet_repository_storage_moves.rb +++ b/spec/factories/snippet_repository_storage_moves.rb @@ -1,29 +1,29 @@ # frozen_string_literal: true FactoryBot.define do - factory :snippet_repository_storage_move, class: 'SnippetRepositoryStorageMove' do + factory :snippet_repository_storage_move, class: 'Snippets::RepositoryStorageMove' do container { association(:snippet) } source_storage_name { 'default' } trait :scheduled do - state { SnippetRepositoryStorageMove.state_machines[:state].states[:scheduled].value } + state { Snippets::RepositoryStorageMove.state_machines[:state].states[:scheduled].value } end trait :started do - state { SnippetRepositoryStorageMove.state_machines[:state].states[:started].value } + state { Snippets::RepositoryStorageMove.state_machines[:state].states[:started].value } end trait :replicated do - state { SnippetRepositoryStorageMove.state_machines[:state].states[:replicated].value } + state { Snippets::RepositoryStorageMove.state_machines[:state].states[:replicated].value } end trait :finished do - state { SnippetRepositoryStorageMove.state_machines[:state].states[:finished].value } + state { Snippets::RepositoryStorageMove.state_machines[:state].states[:finished].value } end trait :failed do - state { SnippetRepositoryStorageMove.state_machines[:state].states[:failed].value } + state { Snippets::RepositoryStorageMove.state_machines[:state].states[:failed].value } end end end diff --git a/spec/factories_spec.rb b/spec/factories_spec.rb index 38ade20de28..56d643d0cc9 100644 --- a/spec/factories_spec.rb +++ b/spec/factories_spec.rb @@ -5,6 +5,37 @@ require 'spec_helper' RSpec.describe 'factories' do include Database::DatabaseHelpers + # https://gitlab.com/groups/gitlab-org/-/epics/5464 tracks the remaining + # skipped traits. + # + # Consider adding a code comment if a trait cannot produce a valid object. + def skipped_traits + [ + [:audit_event, :unauthenticated], + [:ci_build_trace_chunk, :fog_with_data], + [:ci_job_artifact, :remote_store], + [:ci_job_artifact, :raw], + [:ci_job_artifact, :gzip], + [:ci_job_artifact, :correct_checksum], + [:environment, :non_playable], + [:composer_cache_file, :object_storage], + [:debian_project_component_file, :object_storage], + [:debian_project_distribution, :object_storage], + [:debian_file_metadatum, :unknown], + [:package_file, :object_storage], + [:pages_domain, :without_certificate], + [:pages_domain, :without_key], + [:pages_domain, :with_missing_chain], + [:pages_domain, :with_trusted_chain], + [:pages_domain, :with_trusted_expired_chain], + [:pages_domain, :explicit_ecdsa], + [:project_member, :blocked], + [:project, :remote_mirror], + [:remote_mirror, :ssh], + [:user_preference, :only_comments] + ] + end + shared_examples 'factory' do |factory| describe "#{factory.name} factory" do it 'does not raise error when built' do @@ -16,8 +47,10 @@ RSpec.describe 'factories' do end factory.definition.defined_traits.map(&:name).each do |trait_name| - describe "linting #{trait_name} trait" do - skip 'does not raise error when created' do + describe "linting :#{trait_name} trait" do + it 'does not raise error when created' do + pending("Trait skipped linting due to legacy error") if skipped_traits.include?([factory.name, trait_name.to_sym]) + expect { create(factory.name, trait_name) }.not_to raise_error end end @@ -29,9 +62,21 @@ RSpec.describe 'factories' do # and reuse them in other factories. # # However, for some factories we cannot use FactoryDefault because the - # associations must be unique and cannot be reused. + # associations must be unique and cannot be reused, or the factory default + # is being mutated. skip_factory_defaults = %i[ fork_network_member + group_member + import_state + namespace + project_broken_repo + prometheus_alert + prometheus_alert_event + prometheus_metric + self_managed_prometheus_alert_event + users_star_project + wiki_page + wiki_page_meta ].to_set.freeze # Some factories and their corresponding models are based on @@ -46,9 +91,9 @@ RSpec.describe 'factories' do .partition { |factory| skip_factory_defaults.include?(factory.name) } context 'with factory defaults', factory_default: :keep do - let_it_be(:namespace) { create_default(:namespace) } - let_it_be(:project) { create_default(:project, :repository) } - let_it_be(:user) { create_default(:user) } + let_it_be(:namespace) { create_default(:namespace).freeze } + let_it_be(:project) { create_default(:project, :repository).freeze } + let_it_be(:user) { create_default(:user).freeze } before do factories_based_on_view.each do |factory| diff --git a/spec/features/admin/admin_projects_spec.rb b/spec/features/admin/admin_projects_spec.rb index aab2e6d7cef..bf280595ec7 100644 --- a/spec/features/admin/admin_projects_spec.rb +++ b/spec/features/admin/admin_projects_spec.rb @@ -92,97 +92,46 @@ RSpec.describe "Admin::Projects" do end end - context 'when `vue_project_members_list` feature flag is enabled', :js do - describe 'admin adds themselves to the project' do - before do - project.add_maintainer(user) - stub_feature_flags(invite_members_group_modal: false) - end - - it 'adds admin to the project as developer', :js do - visit project_project_members_path(project) - - page.within '.invite-users-form' do - select2(current_user.id, from: '#user_ids', multiple: true) - select 'Developer', from: 'access_level' - end - - click_button 'Invite' - - expect(find_member_row(current_user)).to have_content('Developer') - end + describe 'admin adds themselves to the project', :js do + before do + project.add_maintainer(user) + stub_feature_flags(invite_members_group_modal: false) end - describe 'admin removes themselves from the project' do - before do - project.add_maintainer(user) - project.add_developer(current_user) - end - - it 'removes admin from the project' do - visit project_project_members_path(project) - - expect(find_member_row(current_user)).to have_content('Developer') + it 'adds admin to the project as developer' do + visit project_project_members_path(project) - page.within find_member_row(current_user) do - click_button 'Leave' - end + page.within '.invite-users-form' do + select2(current_user.id, from: '#user_ids', multiple: true) + select 'Developer', from: 'access_level' + end - page.within('[role="dialog"]') do - click_button('Leave') - end + click_button 'Invite' - expect(current_path).to match dashboard_projects_path - end + expect(find_member_row(current_user)).to have_content('Developer') end end - context 'when `vue_project_members_list` feature flag is disabled' do + describe 'admin removes themselves from the project', :js do before do - stub_feature_flags(vue_project_members_list: false) + project.add_maintainer(user) + project.add_developer(current_user) end - describe 'admin adds themselves to the project' do - before do - project.add_maintainer(user) - stub_feature_flags(invite_members_group_modal: false) - end - - it 'adds admin to the project as developer', :js do - visit project_project_members_path(project) - - page.within '.invite-users-form' do - select2(current_user.id, from: '#user_ids', multiple: true) - select 'Developer', from: 'access_level' - end + it 'removes admin from the project' do + visit project_project_members_path(project) - click_button 'Invite' + expect(find_member_row(current_user)).to have_content('Developer') - page.within '.content-list' do - expect(page).to have_content(current_user.name) - expect(page).to have_content('Developer') - end + page.within find_member_row(current_user) do + click_button 'Leave' end - end - describe 'admin removes themselves from the project' do - before do - project.add_maintainer(user) - project.add_developer(current_user) + page.within('[role="dialog"]') do + click_button('Leave') end - it 'removes admin from the project' do - visit project_project_members_path(project) - - page.within '.content-list' do - expect(page).to have_content(current_user.name) - expect(page).to have_content('Developer') - end - - find(:css, '.content-list li', text: current_user.name).find(:css, 'a.btn-danger').click - - expect(page).not_to have_selector(:css, '.content-list') - end + expect(current_path).to match dashboard_projects_path end end end diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index 52f39f65bd0..249621f5835 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -384,7 +384,20 @@ RSpec.describe 'Admin updates settings' do click_button 'Save changes' end - expect(current_settings.repository_storages_weighted_default).to be 50 + expect(current_settings.repository_storages_weighted).to eq('default' => 50) + end + + it 'still saves when settings are outdated' do + current_settings.update_attribute :repository_storages_weighted, { 'default' => 100, 'outdated' => 100 } + + visit repository_admin_application_settings_path + + page.within('.as-repository-storage') do + fill_in 'application_setting_repository_storages_weighted_default', with: 50 + click_button 'Save changes' + end + + expect(current_settings.repository_storages_weighted).to eq('default' => 50) end end diff --git a/spec/features/admin/dashboard_spec.rb b/spec/features/admin/dashboard_spec.rb index c040811ada1..618fae3e46b 100644 --- a/spec/features/admin/dashboard_spec.rb +++ b/spec/features/admin/dashboard_spec.rb @@ -30,7 +30,6 @@ RSpec.describe 'admin visits dashboard' do describe 'Users statistic' do let_it_be(:users_statistics) { create(:users_statistics) } - let_it_be(:users_count_label) { Gitlab.ee? ? 'Billable users 71' : 'Active users 71' } it 'shows correct amounts of users', :aggregate_failures do visit admin_dashboard_stats_path @@ -42,9 +41,16 @@ RSpec.describe 'admin visits dashboard' do expect(page).to have_content('Users with highest role Maintainer 6') expect(page).to have_content('Users with highest role Owner 5') expect(page).to have_content('Bots 2') + + if Gitlab.ee? + expect(page).to have_content('Billable users 69') + else + expect(page).not_to have_content('Billable users 69') + end + expect(page).to have_content('Blocked users 7') expect(page).to have_content('Total users 78') - expect(page).to have_content(users_count_label) + expect(page).to have_content('Active users 71') end end end diff --git a/spec/features/alerts_settings/user_views_alerts_settings_spec.rb b/spec/features/alerts_settings/user_views_alerts_settings_spec.rb index 07c87f98eb6..60f2f776595 100644 --- a/spec/features/alerts_settings/user_views_alerts_settings_spec.rb +++ b/spec/features/alerts_settings/user_views_alerts_settings_spec.rb @@ -19,7 +19,6 @@ RSpec.describe 'Alert integrations settings form', :js do describe 'when viewing alert integrations as a maintainer' do context 'with the default page permissions' do before do - stub_feature_flags(multiple_http_integrations_custom_mapping: false) visit project_settings_operations_path(project, anchor: 'js-alert-management-settings') wait_for_requests end @@ -30,8 +29,8 @@ RSpec.describe 'Alert integrations settings form', :js do end end - it 'shows the new alerts setting form' do - expect(page).to have_content('1. Select integration type') + it 'shows the integrations list title' do + expect(page).to have_content('Current integrations') end end end @@ -44,7 +43,7 @@ RSpec.describe 'Alert integrations settings form', :js do wait_for_requests end - it 'shows the old alerts setting form' do + it 'does not have rights to access the setting form' do expect(page).not_to have_selector('.incident-management-list') expect(page).not_to have_selector('#js-alert-management-settings') end diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index 2d6b669f28b..2392f9d2f8a 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -13,8 +13,6 @@ RSpec.describe 'Issue Boards', :js do let_it_be(:user2) { create(:user) } before do - stub_feature_flags(board_new_list: false) - project.add_maintainer(user) project.add_maintainer(user2) @@ -68,6 +66,8 @@ RSpec.describe 'Issue Boards', :js do let_it_be(:issue10) { create(:labeled_issue, project: project, title: 'issue +', description: 'A+ great issue', labels: [a_plus]) } before do + stub_feature_flags(board_new_list: false) + visit project_board_path(project, board) wait_for_requests @@ -168,19 +168,6 @@ RSpec.describe 'Issue Boards', :js do expect(page).to have_selector('.board', count: 3) end - it 'removes checkmark in new list dropdown after deleting' do - click_button 'Add list' - wait_for_requests - - find('.js-new-board-list').click - - remove_list - - wait_for_requests - - expect(page).to have_selector('.board', count: 3) - end - it 'infinite scrolls list' do create_list(:labeled_issue, 50, project: project, labels: [planning]) @@ -311,7 +298,7 @@ RSpec.describe 'Issue Boards', :js do it 'shows issue count on the list' do page.within(find(".board:nth-child(2)")) do - expect(page.find('.js-issue-size')).to have_text(total_planning_issues) + expect(page.find('[data-testid="board-items-count"]')).to have_text(total_planning_issues) expect(page).not_to have_selector('.js-max-issue-size') end end @@ -321,6 +308,7 @@ RSpec.describe 'Issue Boards', :js do context 'new list' do it 'shows all labels in new list dropdown' do click_button 'Add list' + wait_for_requests page.within('.dropdown-menu-issues-board-new') do diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index 08bc70d7116..c79bf2abff1 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -72,36 +72,6 @@ RSpec.describe 'Issue Boards', :js do end end - it 'removes card from board when clicking' do - click_card(card) - - page.within('.issue-boards-sidebar') do - click_button 'Remove from board' - end - - wait_for_requests - - page.within(find('.board:nth-child(2)')) do - expect(page).to have_selector('.board-card', count: 1) - end - end - - it 'does not show remove button for backlog or closed issues' do - create(:issue, project: project) - create(:issue, :closed, project: project) - - visit project_board_path(project, board) - wait_for_requests - - click_card(find('.board:nth-child(1)').first('.board-card')) - - expect(find('.issue-boards-sidebar')).not_to have_button 'Remove from board' - - click_card(find('.board:nth-child(3)').first('.board-card')) - - expect(find('.issue-boards-sidebar')).not_to have_button 'Remove from board' - end - context 'assignee' do it 'updates the issues assignee' do click_card(card) diff --git a/spec/features/boards/user_adds_lists_to_board_spec.rb b/spec/features/boards/user_adds_lists_to_board_spec.rb new file mode 100644 index 00000000000..b9945207bb2 --- /dev/null +++ b/spec/features/boards/user_adds_lists_to_board_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'User adds lists', :js do + using RSpec::Parameterized::TableSyntax + + let_it_be(:group) { create(:group, :nested) } + let_it_be(:project) { create(:project, :public, namespace: group) } + let_it_be(:group_board) { create(:board, group: group) } + let_it_be(:project_board) { create(:board, project: project) } + let_it_be(:user) { create(:user) } + + let_it_be(:milestone) { create(:milestone, project: project) } + + let_it_be(:group_label) { create(:group_label, group: group) } + let_it_be(:project_label) { create(:label, project: project) } + let_it_be(:group_backlog_list) { create(:backlog_list, board: group_board) } + let_it_be(:project_backlog_list) { create(:backlog_list, board: project_board) } + + let_it_be(:issue) { create(:labeled_issue, project: project, labels: [group_label, project_label]) } + + before_all do + project.add_maintainer(user) + group.add_owner(user) + end + + where(:board_type, :graphql_board_lists_enabled, :board_new_list_enabled) do + :project | true | true + :project | false | true + :project | true | false + :project | false | false + :group | true | true + :group | false | true + :group | true | false + :group | false | false + end + + with_them do + before do + sign_in(user) + + set_cookie('sidebar_collapsed', 'true') + + stub_feature_flags( + graphql_board_lists: graphql_board_lists_enabled, + board_new_list: board_new_list_enabled + ) + + if board_type == :project + visit project_board_path(project, project_board) + elsif board_type == :group + visit group_board_path(group, group_board) + end + + wait_for_all_requests + end + + it 'creates new column for label containing labeled issue' do + click_button button_text(board_new_list_enabled) + wait_for_all_requests + + select_label(board_new_list_enabled, group_label) + + wait_for_all_requests + + expect(page).to have_selector('.board', text: group_label.title) + expect(find('.board:nth-child(2) .board-card')).to have_content(issue.title) + end + end + + def select_label(board_new_list_enabled, label) + if board_new_list_enabled + page.within('.board-add-new-list') do + find('label', text: label.title).click + click_button 'Add' + end + else + page.within('.dropdown-menu-issues-board-new') do + click_link label.title + end + end + end + + def button_text(board_new_list_enabled) + if board_new_list_enabled + 'Create list' + else + 'Add list' + end + end +end diff --git a/spec/features/commit_spec.rb b/spec/features/commit_spec.rb index 02754cc803e..80a30ab01b2 100644 --- a/spec/features/commit_spec.rb +++ b/spec/features/commit_spec.rb @@ -35,9 +35,8 @@ RSpec.describe 'Commit' do end end - context "pagination enabled" do + describe "pagination" do before do - stub_feature_flags(paginate_commit_view: true) stub_const("Projects::CommitController::COMMIT_DIFFS_PER_PAGE", 1) visit project_commit_path(project, commit) @@ -61,18 +60,5 @@ RSpec.describe 'Commit' do expect(page).to have_selector(".files ##{files[1].file_hash}") end end - - context "pagination disabled" do - before do - stub_feature_flags(paginate_commit_view: false) - - visit project_commit_path(project, commit) - end - - it "shows both diffs on the page" do - expect(page).to have_selector(".files ##{files[0].file_hash}") - expect(page).to have_selector(".files ##{files[1].file_hash}") - end - end end end diff --git a/spec/features/dashboard/group_spec.rb b/spec/features/dashboard/group_spec.rb index 0b99fed2a2d..bc6f449edc5 100644 --- a/spec/features/dashboard/group_spec.rb +++ b/spec/features/dashboard/group_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'Dashboard Group' do it 'creates new group', :js do visit dashboard_groups_path - find('.btn-success').click + find('[data-testid="new-group-button"]').click new_name = 'Samurai' fill_in 'group_name', with: new_name diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb index 8705c22c41a..d7330b5267b 100644 --- a/spec/features/dashboard/projects_spec.rb +++ b/spec/features/dashboard/projects_spec.rb @@ -198,14 +198,6 @@ RSpec.describe 'Dashboard Projects' do it_behaves_like 'hidden pipeline status' end - context 'when dashboard_pipeline_status is disabled' do - before do - stub_feature_flags(dashboard_pipeline_status: false) - end - - it_behaves_like 'hidden pipeline status' - end - context "when last_pipeline is missing" do before do project.last_pipeline.delete diff --git a/spec/features/discussion_comments/commit_spec.rb b/spec/features/discussion_comments/commit_spec.rb index 32c0ba2a9a7..261e9fb9f3b 100644 --- a/spec/features/discussion_comments/commit_spec.rb +++ b/spec/features/discussion_comments/commit_spec.rb @@ -18,7 +18,7 @@ RSpec.describe 'Thread Comments Commit', :js do visit project_commit_path(project, sample_commit.id) end - it_behaves_like 'thread comments', 'commit' + it_behaves_like 'thread comments for commit and snippet', 'commit' it 'has class .js-note-emoji' do expect(page).to have_css('.js-note-emoji') diff --git a/spec/features/discussion_comments/issue_spec.rb b/spec/features/discussion_comments/issue_spec.rb index 86743e31fbd..ebb57b37918 100644 --- a/spec/features/discussion_comments/issue_spec.rb +++ b/spec/features/discussion_comments/issue_spec.rb @@ -8,13 +8,11 @@ RSpec.describe 'Thread Comments Issue', :js do let(:issue) { create(:issue, project: project) } before do - stub_feature_flags(remove_comment_close_reopen: false) - project.add_maintainer(user) sign_in(user) visit project_issue_path(project, issue) end - it_behaves_like 'thread comments', 'issue' + it_behaves_like 'thread comments for issue, epic and merge request', 'issue' end diff --git a/spec/features/discussion_comments/merge_request_spec.rb b/spec/features/discussion_comments/merge_request_spec.rb index 82dcdf9f918..f60d7da6a30 100644 --- a/spec/features/discussion_comments/merge_request_spec.rb +++ b/spec/features/discussion_comments/merge_request_spec.rb @@ -9,7 +9,6 @@ RSpec.describe 'Thread Comments Merge Request', :js do before do stub_feature_flags(remove_resolve_note: false) - stub_feature_flags(remove_comment_close_reopen: false) project.add_maintainer(user) sign_in(user) @@ -20,5 +19,5 @@ RSpec.describe 'Thread Comments Merge Request', :js do wait_for_requests end - it_behaves_like 'thread comments', 'merge request' + it_behaves_like 'thread comments for issue, epic and merge request', 'merge request' end diff --git a/spec/features/discussion_comments/snippets_spec.rb b/spec/features/discussion_comments/snippets_spec.rb index 42053e571e9..ca0a6d6e1c5 100644 --- a/spec/features/discussion_comments/snippets_spec.rb +++ b/spec/features/discussion_comments/snippets_spec.rb @@ -22,7 +22,7 @@ RSpec.describe 'Thread Comments Snippet', :js do visit project_snippet_path(project, snippet) end - it_behaves_like 'thread comments', 'snippet' + it_behaves_like 'thread comments for commit and snippet', 'snippet' end context 'with personal snippets' do @@ -32,6 +32,6 @@ RSpec.describe 'Thread Comments Snippet', :js do visit snippet_path(snippet) end - it_behaves_like 'thread comments', 'snippet' + it_behaves_like 'thread comments for commit and snippet', 'snippet' end end diff --git a/spec/features/expand_collapse_diffs_spec.rb b/spec/features/expand_collapse_diffs_spec.rb index 55bdf4c244e..cbd1ae628d1 100644 --- a/spec/features/expand_collapse_diffs_spec.rb +++ b/spec/features/expand_collapse_diffs_spec.rb @@ -17,7 +17,6 @@ RSpec.describe 'Expand and collapse diffs', :js do # Ensure that undiffable.md is in .gitattributes project.repository.copy_gitattributes(branch) visit project_commit_path(project, project.commit(branch)) - execute_script('window.ajaxUris = []; $(document).ajaxSend(function(event, xhr, settings) { ajaxUris.push(settings.url) });') end def file_container(filename) @@ -191,10 +190,6 @@ RSpec.describe 'Expand and collapse diffs', :js do expect(small_diff).to have_selector('.code') expect(small_diff).not_to have_selector('.nothing-here-block') end - - it 'does not make a new HTTP request' do - expect(evaluate_script('ajaxUris')).not_to include(a_string_matching('small_diff.md')) - end end end @@ -264,7 +259,6 @@ RSpec.describe 'Expand and collapse diffs', :js do find('.note-textarea') wait_for_requests - execute_script('window.ajaxUris = []; $(document).ajaxSend(function(event, xhr, settings) { ajaxUris.push(settings.url) });') end it 'reloads the page with all diffs expanded' do @@ -300,10 +294,6 @@ RSpec.describe 'Expand and collapse diffs', :js do expect(small_diff).to have_selector('.code') expect(small_diff).not_to have_selector('.nothing-here-block') end - - it 'does not make a new HTTP request' do - expect(evaluate_script('ajaxUris')).not_to include(a_string_matching('small_diff.md')) - end end end end diff --git a/spec/features/groups/container_registry_spec.rb b/spec/features/groups/container_registry_spec.rb index cacabdda22d..65374263f45 100644 --- a/spec/features/groups/container_registry_spec.rb +++ b/spec/features/groups/container_registry_spec.rb @@ -67,7 +67,13 @@ RSpec.describe 'Container Registry', :js do end it 'shows the image title' do - expect(page).to have_content 'my/image tags' + expect(page).to have_content 'my/image' + end + + it 'shows the image tags' do + expect(page).to have_content 'Image tags' + first_tag = first('[data-testid="name"]') + expect(first_tag).to have_content 'latest' end it 'user removes a specific tag from container repository' do diff --git a/spec/features/groups/members/list_members_spec.rb b/spec/features/groups/members/list_members_spec.rb index b0a896ec8cb..b81949da85d 100644 --- a/spec/features/groups/members/list_members_spec.rb +++ b/spec/features/groups/members/list_members_spec.rb @@ -47,4 +47,46 @@ RSpec.describe 'Groups > Members > List members', :js do expect(first_row).to have_selector('gl-emoji[data-name="smirk"]') end end + + describe 'when user has 2FA enabled' do + let_it_be(:admin) { create(:admin) } + let_it_be(:user_with_2fa) { create(:user, :two_factor_via_otp) } + + before do + group.add_guest(user_with_2fa) + end + + it 'shows 2FA badge to user with "Owner" access level' do + group.add_owner(user1) + + visit group_group_members_path(group) + + expect(find_member_row(user_with_2fa)).to have_content('2FA') + end + + it 'shows 2FA badge to admins' do + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) + + visit group_group_members_path(group) + + expect(find_member_row(user_with_2fa)).to have_content('2FA') + end + + it 'does not show 2FA badge to users with access level below "Owner"' do + group.add_maintainer(user1) + + visit group_group_members_path(group) + + expect(find_member_row(user_with_2fa)).not_to have_content('2FA') + end + + it 'shows 2FA badge to themselves' do + sign_in(user_with_2fa) + + visit group_group_members_path(group) + + expect(find_member_row(user_with_2fa)).to have_content('2FA') + end + end end diff --git a/spec/features/groups/members/manage_members_spec.rb b/spec/features/groups/members/manage_members_spec.rb index c27d0afba6f..3b637a10abe 100644 --- a/spec/features/groups/members/manage_members_spec.rb +++ b/spec/features/groups/members/manage_members_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'Groups > Members > Manage members' do sign_in(user1) end - shared_examples 'includes the correct Invite Members link' do |should_include, should_not_include| + shared_examples 'includes the correct Invite link' do |should_include, should_not_include| it 'includes either the form or the modal trigger' do group.add_owner(user1) @@ -31,15 +31,13 @@ RSpec.describe 'Groups > Members > Manage members' do stub_feature_flags(invite_members_group_modal: true) end - it_behaves_like 'includes the correct Invite Members link', '.js-invite-members-trigger', '.invite-users-form' + it_behaves_like 'includes the correct Invite link', '.js-invite-members-trigger', '.invite-users-form' + it_behaves_like 'includes the correct Invite link', '.js-invite-group-trigger', '.invite-group-form' end context 'when Invite Members modal is disabled' do - before do - stub_feature_flags(invite_members_group_modal: false) - end - - it_behaves_like 'includes the correct Invite Members link', '.invite-users-form', '.js-invite-members-trigger' + it_behaves_like 'includes the correct Invite link', '.invite-users-form', '.js-invite-members-trigger' + it_behaves_like 'includes the correct Invite link', '.invite-group-form', '.js-invite-group-trigger' end it 'update user to owner level', :js do diff --git a/spec/features/groups/settings/user_searches_in_settings_spec.rb b/spec/features/groups/settings/user_searches_in_settings_spec.rb new file mode 100644 index 00000000000..819d0c4faba --- /dev/null +++ b/spec/features/groups/settings/user_searches_in_settings_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'User searches group settings', :js do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :repository, namespace: group) } + + before do + group.add_owner(user) + sign_in(user) + end + + context 'in general settings page' do + let(:visit_path) { edit_group_path(group) } + + it_behaves_like 'can search settings with feature flag check', 'Naming', 'Permissions' + end + + context 'in Repository page' do + before do + visit group_settings_repository_path(group) + end + + it_behaves_like 'can search settings', 'Deploy tokens', 'Default initial branch name' + end + + context 'in CI/CD page' do + before do + visit group_settings_ci_cd_path(group) + end + + it_behaves_like 'can search settings', 'Variables', 'Runners' + end +end diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb index 5067f11be67..4bcba4c21ed 100644 --- a/spec/features/groups/show_spec.rb +++ b/spec/features/groups/show_spec.rb @@ -163,7 +163,6 @@ RSpec.describe 'Group show page' do let!(:project) { create(:project, namespace: group) } before do - stub_feature_flags(vue_notification_dropdown: false) group.add_maintainer(maintainer) sign_in(maintainer) end @@ -171,14 +170,14 @@ RSpec.describe 'Group show page' do it 'is enabled by default' do visit path - expect(page).to have_selector('.notifications-btn:not(.disabled)', visible: true) + expect(page).to have_selector('[data-testid="notification-dropdown"] button:not(.disabled)') end it 'is disabled if emails are disabled' do group.update_attribute(:emails_disabled, true) visit path - expect(page).to have_selector('.notifications-btn.disabled', visible: true) + expect(page).to have_selector('[data-testid="notification-dropdown"] .disabled') end end diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb index c9a0844932a..28b22860f0a 100644 --- a/spec/features/groups_spec.rb +++ b/spec/features/groups_spec.rb @@ -143,7 +143,7 @@ RSpec.describe 'Group' do end end - describe 'create a nested group', :js do + describe 'create a nested group' do let_it_be(:group) { create(:group, path: 'foo') } context 'as admin' do @@ -153,13 +153,21 @@ RSpec.describe 'Group' do visit new_group_path(group, parent_id: group.id) end - it 'creates a nested group' do - fill_in 'Group name', with: 'bar' - fill_in 'Group URL', with: 'bar' - click_button 'Create group' + context 'when admin mode is enabled', :enable_admin_mode do + it 'creates a nested group' do + fill_in 'Group name', with: 'bar' + fill_in 'Group URL', with: 'bar' + click_button 'Create group' - expect(current_path).to eq(group_path('foo/bar')) - expect(page).to have_content("Group 'bar' was successfully created.") + expect(current_path).to eq(group_path('foo/bar')) + expect(page).to have_content("Group 'bar' was successfully created.") + end + end + + context 'when admin mode is disabled' do + it 'is not allowed' do + expect(page).to have_gitlab_http_status(:not_found) + end end end diff --git a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb index d773126e00c..a4e9df604a9 100644 --- a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb +++ b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb @@ -89,6 +89,8 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j before do page.within '.mr-widget-body' do page.click_link 'Resolve all threads in new issue', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid) + + wait_for_all_requests end end diff --git a/spec/features/issues/csv_spec.rb b/spec/features/issues/csv_spec.rb index c93693ec40a..d41a41c4383 100644 --- a/spec/features/issues/csv_spec.rb +++ b/spec/features/issues/csv_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Issues csv' do +RSpec.describe 'Issues csv', :js do let(:user) { create(:user) } let(:project) { create(:project, :public) } let(:milestone) { create(:milestone, title: 'v1.0', project: project) } @@ -17,7 +17,7 @@ RSpec.describe 'Issues csv' do def request_csv(params = {}) visit project_issues_path(project, params) page.within('.nav-controls') do - click_on 'Export as CSV' + find('[data-testid="export-csv-button"]').click end click_on 'Export issues' end diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb index e2087868035..e6ebc37ba59 100644 --- a/spec/features/issues/gfm_autocomplete_spec.rb +++ b/spec/features/issues/gfm_autocomplete_spec.rb @@ -6,6 +6,7 @@ RSpec.describe 'GFM autocomplete', :js do let_it_be(:user_xss_title) { 'eve <img src=x onerror=alert(2)<img src=x onerror=alert(1)>' } let_it_be(:user_xss) { create(:user, name: user_xss_title, username: 'xss.user') } let_it_be(:user) { create(:user, name: '💃speciąl someone💃', username: 'someone.special') } + let_it_be(:user2) { create(:user, name: 'Marge Simpson', username: 'msimpson') } let_it_be(:group) { create(:group, name: 'Ancestor') } let_it_be(:child_group) { create(:group, parent: group, name: 'My group') } let_it_be(:project) { create(:project, group: child_group) } @@ -16,6 +17,7 @@ RSpec.describe 'GFM autocomplete', :js do before_all do project.add_maintainer(user) project.add_maintainer(user_xss) + project.add_maintainer(user2) end describe 'when tribute_autocomplete feature flag is off' do @@ -29,289 +31,218 @@ RSpec.describe 'GFM autocomplete', :js do end it 'updates issue description with GFM reference' do - find('.js-issuable-edit').click + click_button 'Edit title and description' wait_for_requests - simulate_input('#issue-description', "@#{user.name[0...3]}") + fill_in 'Description', with: "@#{user.name[0...3]}" wait_for_requests - find('.atwho-view .cur').click + find_highlighted_autocomplete_item.click click_button 'Save changes' wait_for_requests - expect(find('.description')).to have_content(user.to_reference) + expect(find('.description')).to have_text(user.to_reference) end it 'opens quick action autocomplete when updating description' do - find('.js-issuable-edit').click + click_button 'Edit title and description' - find('#issue-description').native.send_keys('/') + fill_in 'Description', with: '/' - expect(page).to have_selector('.atwho-container') + expect(find_autocomplete_menu).to be_visible end it 'opens autocomplete menu when field starts with text' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('@') - end + fill_in 'Comment', with: '@' - expect(page).to have_selector('.atwho-container') + expect(find_autocomplete_menu).to be_visible end it 'opens autocomplete menu for Issues when field starts with text with item escaping HTML characters' do issue_xss_title = 'This will execute alert<img src=x onerror=alert(2)<img src=x onerror=alert(1)>' create(:issue, project: project, title: issue_xss_title) - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('#') - end + fill_in 'Comment', with: '#' wait_for_requests - expect(page).to have_selector('.atwho-container') - - page.within '.atwho-container #at-view-issues' do - expect(page.all('li').first.text).to include(issue_xss_title) - end + expect(find_autocomplete_menu).to have_text(issue_xss_title) end it 'opens autocomplete menu for Username when field starts with text with item escaping HTML characters' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('@ev') - end + fill_in 'Comment', with: '@ev' wait_for_requests - expect(page).to have_selector('.atwho-container') - - page.within '.atwho-container #at-view-users' do - expect(find('li').text).to have_content(user_xss.username) - end + expect(find_highlighted_autocomplete_item).to have_text(user_xss.username) end it 'opens autocomplete menu for Milestone when field starts with text with item escaping HTML characters' do milestone_xss_title = 'alert milestone <img src=x onerror="alert(\'Hello xss\');" a' create(:milestone, project: project, title: milestone_xss_title) - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('%') - end + fill_in 'Comment', with: '%' wait_for_requests - expect(page).to have_selector('.atwho-container') - - page.within '.atwho-container #at-view-milestones' do - expect(find('li').text).to have_content('alert milestone') - end + expect(find_autocomplete_menu).to have_text('alert milestone') end it 'doesnt open autocomplete menu character is prefixed with text' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('testing') - find('#note-body').native.send_keys('@') - end + fill_in 'Comment', with: 'testing@' - expect(page).not_to have_selector('.atwho-view') + expect(page).not_to have_css('.atwho-view') end it 'doesnt select the first item for non-assignee dropdowns' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys(':') - end - - expect(page).to have_selector('.atwho-container') + fill_in 'Comment', with: ':' wait_for_requests - expect(find('#at-view-58')).not_to have_selector('.cur:first-of-type') + expect(find_autocomplete_menu).not_to have_css('.cur') end it 'does not open autocomplete menu when ":" is prefixed by a number and letters' do - note = find('#note-body') - # Number. - page.within '.timeline-content-form' do - note.native.send_keys('7:') - end - - expect(page).not_to have_selector('.atwho-view') + fill_in 'Comment', with: '7:' + expect(page).not_to have_css('.atwho-view') # ASCII letter. - page.within '.timeline-content-form' do - note.set('') - note.native.send_keys('w:') - end - - expect(page).not_to have_selector('.atwho-view') + fill_in 'Comment', with: 'w:' + expect(page).not_to have_css('.atwho-view') # Non-ASCII letter. - page.within '.timeline-content-form' do - note.set('') - note.native.send_keys('Ё:') - end - - expect(page).not_to have_selector('.atwho-view') + fill_in 'Comment', with: 'Ё:' + expect(page).not_to have_css('.atwho-view') end it 'selects the first item for assignee dropdowns' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('@') - end - - expect(page).to have_selector('.atwho-container') + fill_in 'Comment', with: '@' wait_for_requests - expect(find('#at-view-users')).to have_selector('.cur:first-of-type') + expect(find_autocomplete_menu).to have_css('.cur:first-of-type') end it 'includes items for assignee dropdowns with non-ASCII characters in name' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('') - simulate_input('#note-body', "@#{user.name[0...8]}") - end - - expect(page).to have_selector('.atwho-container') + fill_in 'Comment', with: "@#{user.name[0...8]}" wait_for_requests - expect(find('#at-view-users')).to have_content(user.name) + expect(find_autocomplete_menu).to have_text(user.name) end it 'searches across full name for assignees' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('@speciąlsome') - end + fill_in 'Comment', with: '@speciąlsome' wait_for_requests - expect(find('.atwho-view li', visible: true)).to have_content(user.name) + expect(find_highlighted_autocomplete_item).to have_text(user.name) end - it 'selects the first item for non-assignee dropdowns if a query is entered' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys(':1') - end + it 'shows names that start with the query as the top result' do + fill_in 'Comment', with: '@mar' + + wait_for_requests + + expect(find_highlighted_autocomplete_item).to have_text(user2.name) + end + + it 'shows usernames that start with the query as the top result' do + fill_in 'Comment', with: '@msi' + + wait_for_requests + + expect(find_highlighted_autocomplete_item).to have_text(user2.name) + end + + # Regression test for https://gitlab.com/gitlab-org/gitlab/-/issues/321925 + it 'shows username when pasting then pressing Enter' do + fill_in 'Comment', with: "@#{user.username}\n" + + expect(find_field('Comment').value).to have_text "@#{user.username}" + end - expect(page).to have_selector('.atwho-container') + it 'does not show `@undefined` when pressing `@` then Enter' do + fill_in 'Comment', with: "@\n" + + expect(find_field('Comment').value).to have_text '@' + expect(find_field('Comment').value).not_to have_text '@undefined' + end + + it 'selects the first item for non-assignee dropdowns if a query is entered' do + fill_in 'Comment', with: ':1' wait_for_requests - expect(find('#at-view-58')).to have_selector('.cur:first-of-type') + expect(find_autocomplete_menu).to have_css('.cur:first-of-type') end context 'if a selected value has special characters' do it 'wraps the result in double quotes' do - note = find('#note-body') - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('') - simulate_input('#note-body', "~#{label.title[0]}") - end + fill_in 'Comment', with: "~#{label.title[0]}" - label_item = find('.atwho-view li', text: label.title) + find_highlighted_autocomplete_item.click - expect_to_wrap(true, label_item, note, label.title) + expect(find_field('Comment').value).to have_text("~\"#{label.title}\"") end it "shows dropdown after a new line" do - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys('test') - note.native.send_keys(:enter) - note.native.send_keys(:enter) - note.native.send_keys('@') - end + fill_in 'Comment', with: "test\n\n@" - expect(page).to have_selector('.atwho-container') + expect(find_autocomplete_menu).to be_visible end it "does not show dropdown when preceded with a special character" do - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys("@") - end - - expect(page).to have_selector('.atwho-container') - - page.within '.timeline-content-form' do - note.native.send_keys("@") - end + fill_in 'Comment', with: '@@' - expect(page).to have_selector('.atwho-container', visible: false) - end - - it "does not throw an error if no labels exist" do - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys('~') - end - - expect(page).to have_selector('.atwho-container', visible: false) + expect(page).not_to have_css('.atwho-view') end it 'doesn\'t wrap for assignee values' do - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys("@#{user.username[0]}") - end + fill_in 'Comment', with: "@#{user.username[0]}" - user_item = find('.atwho-view li', text: user.username) + find_highlighted_autocomplete_item.click - expect_to_wrap(false, user_item, note, user.username) + expect(find_field('Comment').value).to have_text("@#{user.username}") end it 'doesn\'t wrap for emoji values' do - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys(":cartwheel_") - end + fill_in 'Comment', with: ':cartwheel_' - emoji_item = find('.atwho-view li', text: 'cartwheel_tone1') + find_highlighted_autocomplete_item.click - expect_to_wrap(false, emoji_item, note, 'cartwheel_tone1') + expect(find_field('Comment').value).to have_text('cartwheel_tone1') end it 'doesn\'t open autocomplete after non-word character' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys("@#{user.username[0..2]}!") - end + fill_in 'Comment', with: "@#{user.username[0..2]}!" - expect(page).not_to have_selector('.atwho-view') + expect(page).not_to have_css('.atwho-view') end it 'doesn\'t open autocomplete if there is no space before' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys("hello:#{user.username[0..2]}") - end + fill_in 'Comment', with: "hello:#{user.username[0..2]}" - expect(page).not_to have_selector('.atwho-view') + expect(page).not_to have_css('.atwho-view') end it 'triggers autocomplete after selecting a quick action' do - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys('/as') - end + fill_in 'Comment', with: '/as' - find('.atwho-view li', text: '/assign') - note.native.send_keys(:tab) + find_highlighted_autocomplete_item.click - user_item = find('.atwho-view li', text: user.username) - expect(user_item).to have_content(user.username) + expect(find_autocomplete_menu).to have_text(user.username) end it 'does not limit quick actions autocomplete list to 5' do - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys('/') - end + fill_in 'Comment', with: '/' - expect(page).to have_selector('.atwho-view li', minimum: 6, visible: true) + expect(find_autocomplete_menu).to have_css('li', minimum: 6) end end @@ -328,30 +259,23 @@ RSpec.describe 'GFM autocomplete', :js do it 'lists users who are currently not assigned to the issue when using /assign' do visit project_issue_path(project, issue_assignee) - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys('/as') - end - - find('.atwho-view li', text: '/assign') - note.native.send_keys(:tab) + fill_in 'Comment', with: '/as' - wait_for_requests + find_highlighted_autocomplete_item.click - expect(find('#at-view-users .atwho-view-ul')).not_to have_content(user.username) - expect(find('#at-view-users .atwho-view-ul')).to have_content(unassigned_user.username) + expect(find_autocomplete_menu).not_to have_text(user.username) + expect(find_autocomplete_menu).to have_text(unassigned_user.username) end it 'shows dropdown on new issue form' do visit new_project_issue_path(project) - textarea = find('#issue_description') - textarea.native.send_keys('/ass') - find('.atwho-view li', text: '/assign') - textarea.native.send_keys(:tab) + fill_in 'Description', with: '/ass' - expect(find('#at-view-users .atwho-view-ul')).to have_content(unassigned_user.username) - expect(find('#at-view-users .atwho-view-ul')).to have_content(user.username) + find_highlighted_autocomplete_item.click + + expect(find_autocomplete_menu).to have_text(unassigned_user.username) + expect(find_autocomplete_menu).to have_text(user.username) end end @@ -360,80 +284,62 @@ RSpec.describe 'GFM autocomplete', :js do label_xss_title = 'alert label <img src=x onerror="alert(\'Hello xss\');" a' create(:label, project: project, title: label_xss_title) - note = find('#note-body') - - # It should show all the labels on "~". - type(note, '~') + fill_in 'Comment', with: '~' wait_for_requests - page.within '.atwho-container #at-view-labels' do - expect(find('.atwho-view-ul').text).to have_content('alert label') - end + expect(find_autocomplete_menu).to have_text('alert label') end it 'allows colons when autocompleting scoped labels' do create(:label, project: project, title: 'scoped:label') - note = find('#note-body') - type(note, '~scoped:') + fill_in 'Comment', with: '~scoped:' wait_for_requests - page.within '.atwho-container #at-view-labels' do - expect(find('.atwho-view-ul').text).to have_content('scoped:label') - end + expect(find_autocomplete_menu).to have_text('scoped:label') end it 'allows colons when autocompleting scoped labels with double colons' do create(:label, project: project, title: 'scoped::label') - note = find('#note-body') - type(note, '~scoped::') + fill_in 'Comment', with: '~scoped::' wait_for_requests - page.within '.atwho-container #at-view-labels' do - expect(find('.atwho-view-ul').text).to have_content('scoped::label') - end + expect(find_autocomplete_menu).to have_text('scoped::label') end it 'allows spaces when autocompleting multi-word labels' do create(:label, project: project, title: 'Accepting merge requests') - note = find('#note-body') - type(note, '~Accepting merge') + fill_in 'Comment', with: '~Accepting merge' wait_for_requests - page.within '.atwho-container #at-view-labels' do - expect(find('.atwho-view-ul').text).to have_content('Accepting merge requests') - end + expect(find_autocomplete_menu).to have_text('Accepting merge requests') end it 'only autocompletes the latest label' do create(:label, project: project, title: 'Accepting merge requests') create(:label, project: project, title: 'Accepting job applicants') - note = find('#note-body') - type(note, '~Accepting merge requests foo bar ~Accepting job') + fill_in 'Comment', with: '~Accepting merge requests foo bar ~Accepting job' wait_for_requests - page.within '.atwho-container #at-view-labels' do - expect(find('.atwho-view-ul').text).to have_content('Accepting job applicants') - end + expect(find_autocomplete_menu).to have_text('Accepting job applicants') end it 'does not autocomplete labels if no tilde is typed' do create(:label, project: project, title: 'Accepting merge requests') - note = find('#note-body') - type(note, 'Accepting merge') + fill_in 'Comment', with: 'Accepting merge' wait_for_requests - expect(page).not_to have_css('.atwho-container #at-view-labels') + expect(page).not_to have_css('.atwho-view') end end @@ -443,7 +349,7 @@ RSpec.describe 'GFM autocomplete', :js do # This is meant to protect against this issue https://gitlab.com/gitlab-org/gitlab/-/issues/228729 it 'keeps autocomplete key listeners' do visit project_issue_path(project, issue) - note = find('#note-body') + note = find_field('Comment') start_comment_with_emoji(note, '.atwho-view li') @@ -459,17 +365,11 @@ RSpec.describe 'GFM autocomplete', :js do shared_examples 'autocomplete suggestions' do it 'suggests objects correctly' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys(object.class.reference_prefix) - end - - page.within '.atwho-container' do - expect(page).to have_content(object.title) + fill_in 'Comment', with: object.class.reference_prefix - find('ul li').click - end + find_autocomplete_menu.find('li').click - expect(find('.new-note #note-body').value).to include(expected_body) + expect(find_field('Comment').value).to have_text(expected_body) end end @@ -502,10 +402,40 @@ RSpec.describe 'GFM autocomplete', :js do end context 'milestone' do - let!(:object) { create(:milestone, project: project) } - let(:expected_body) { object.to_reference } + let_it_be(:milestone_expired) { create(:milestone, project: project, due_date: 5.days.ago) } + let_it_be(:milestone_no_duedate) { create(:milestone, project: project, title: 'Foo - No due date') } + let_it_be(:milestone1) { create(:milestone, project: project, title: 'Milestone-1', due_date: 20.days.from_now) } + let_it_be(:milestone2) { create(:milestone, project: project, title: 'Milestone-2', due_date: 15.days.from_now) } + let_it_be(:milestone3) { create(:milestone, project: project, title: 'Milestone-3', due_date: 10.days.from_now) } - it_behaves_like 'autocomplete suggestions' + before do + fill_in 'Comment', with: '/milestone %' + + wait_for_requests + end + + it 'shows milestons list in the autocomplete menu' do + page.within(find_autocomplete_menu) do + expect(page).to have_selector('li', count: 5) + end + end + + it 'shows expired milestone at the bottom of the list' do + page.within(find_autocomplete_menu) do + expect(page.find('li:last-child')).to have_content milestone_expired.title + end + end + + it 'shows milestone due earliest at the top of the list' do + page.within(find_autocomplete_menu) do + aggregate_failures do + expect(page.all('li')[0]).to have_content milestone3.title + expect(page.all('li')[1]).to have_content milestone2.title + expect(page.all('li')[2]).to have_content milestone1.title + expect(page.all('li')[3]).to have_content milestone_no_duedate.title + end + end + end end end @@ -520,237 +450,160 @@ RSpec.describe 'GFM autocomplete', :js do end it 'updates issue description with GFM reference' do - find('.js-issuable-edit').click + click_button 'Edit title and description' wait_for_requests - simulate_input('#issue-description', "@#{user.name[0...3]}") + fill_in 'Description', with: "@#{user.name[0...3]}" wait_for_requests - find('.tribute-container .highlight', visible: true).click + find_highlighted_tribute_autocomplete_menu.click click_button 'Save changes' wait_for_requests - expect(find('.description')).to have_content(user.to_reference) + expect(find('.description')).to have_text(user.to_reference) end it 'opens autocomplete menu when field starts with text' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('@') - end + fill_in 'Comment', with: '@' - expect(page).to have_selector('.tribute-container', visible: true) + expect(find_tribute_autocomplete_menu).to be_visible end it 'opens autocomplete menu for Issues when field starts with text with item escaping HTML characters' do issue_xss_title = 'This will execute alert<img src=x onerror=alert(2)<img src=x onerror=alert(1)>' create(:issue, project: project, title: issue_xss_title) - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('#') - end + fill_in 'Comment', with: '#' wait_for_requests - expect(page).to have_selector('.tribute-container', visible: true) - - page.within '.tribute-container ul' do - expect(page.all('li').first.text).to include(issue_xss_title) - end + expect(find_tribute_autocomplete_menu).to have_text(issue_xss_title) end it 'opens autocomplete menu for Username when field starts with text with item escaping HTML characters' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('@ev') - end + fill_in 'Comment', with: '@ev' wait_for_requests - expect(page).to have_selector('.tribute-container', visible: true) - - expect(find('.tribute-container ul', visible: true)).to have_text(user_xss.username) + expect(find_tribute_autocomplete_menu).to have_text(user_xss.username) end it 'opens autocomplete menu for Milestone when field starts with text with item escaping HTML characters' do milestone_xss_title = 'alert milestone <img src=x onerror="alert(\'Hello xss\');" a' create(:milestone, project: project, title: milestone_xss_title) - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('%') - end + fill_in 'Comment', with: '%' wait_for_requests - expect(page).to have_selector('.tribute-container', visible: true) - - expect(find('.tribute-container ul', visible: true)).to have_text('alert milestone') + expect(find_tribute_autocomplete_menu).to have_text('alert milestone') end it 'does not open autocomplete menu when trigger character is prefixed with text' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('testing') - find('#note-body').native.send_keys('@') - end + fill_in 'Comment', with: 'testing@' - expect(page).not_to have_selector('.tribute-container', visible: true) + expect(page).not_to have_css('.tribute-container') end it 'does not open autocomplete menu when ":" is prefixed by a number and letters' do - note = find('#note-body') - # Number. - page.within '.timeline-content-form' do - note.native.send_keys('7:') - end - - expect(page).not_to have_selector('.tribute-container', visible: true) + fill_in 'Comment', with: '7:' + expect(page).not_to have_css('.tribute-container') # ASCII letter. - page.within '.timeline-content-form' do - note.set('') - note.native.send_keys('w:') - end - - expect(page).not_to have_selector('.tribute-container', visible: true) + fill_in 'Comment', with: 'w:' + expect(page).not_to have_css('.tribute-container') # Non-ASCII letter. - page.within '.timeline-content-form' do - note.set('') - note.native.send_keys('Ё:') - end - - expect(page).not_to have_selector('.tribute-container', visible: true) + fill_in 'Comment', with: 'Ё:' + expect(page).not_to have_css('.tribute-container') end it 'selects the first item for assignee dropdowns' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('@') - end - - expect(page).to have_selector('.tribute-container', visible: true) + fill_in 'Comment', with: '@' wait_for_requests - expect(find('.tribute-container ul', visible: true)).to have_selector('.highlight:first-of-type') + expect(find_tribute_autocomplete_menu).to have_css('.highlight:first-of-type') end it 'includes items for assignee dropdowns with non-ASCII characters in name' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('') - simulate_input('#note-body', "@#{user.name[0...8]}") - end - - expect(page).to have_selector('.tribute-container', visible: true) + fill_in 'Comment', with: "@#{user.name[0...8]}" wait_for_requests - expect(find('.tribute-container ul', visible: true)).to have_content(user.name) + expect(find_tribute_autocomplete_menu).to have_text(user.name) end it 'selects the first item for non-assignee dropdowns if a query is entered' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys(':1') - end + fill_in 'Comment', with: ':1' wait_for_requests - expect(find('.tribute-container ul', visible: true)).to have_selector('.highlight:first-of-type') + expect(find_tribute_autocomplete_menu).to have_css('.highlight:first-of-type') end context 'when autocompleting for groups' do it 'shows the group when searching for the name of the group' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('@mygroup') - end + fill_in 'Comment', with: '@mygroup' - expect(find('.tribute-container ul', visible: true)).to have_text('My group') + expect(find_tribute_autocomplete_menu).to have_text('My group') end it 'does not show the group when searching for the name of the parent of the group' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('@ancestor') - end + fill_in 'Comment', with: '@ancestor' - expect(find('.tribute-container ul', visible: true)).not_to have_text('My group') + expect(find_tribute_autocomplete_menu).not_to have_text('My group') end end context 'if a selected value has special characters' do it 'wraps the result in double quotes' do - note = find('#note-body') - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('') - simulate_input('#note-body', "~#{label.title[0]}") - end + fill_in 'Comment', with: "~#{label.title[0]}" - label_item = find('.tribute-container ul', text: label.title, visible: true) + find_highlighted_tribute_autocomplete_menu.click - expect_to_wrap(true, label_item, note, label.title) + expect(find_field('Comment').value).to have_text("~\"#{label.title}\"") end it "shows dropdown after a new line" do - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys('test') - note.native.send_keys(:enter) - note.native.send_keys(:enter) - note.native.send_keys('@') - end - - expect(page).to have_selector('.tribute-container', visible: true) - end - - it "does not throw an error if no labels exist" do - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys('~') - end + fill_in 'Comment', with: "test\n\n@" - expect(page).to have_selector('.tribute-container', visible: false) + expect(find_tribute_autocomplete_menu).to be_visible end it 'doesn\'t wrap for assignee values' do - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys("@#{user.username[0]}") - end + fill_in 'Comment', with: "@#{user.username[0..2]}" - user_item = find('.tribute-container ul', text: user.username, visible: true) + find_highlighted_tribute_autocomplete_menu.click - expect_to_wrap(false, user_item, note, user.username) + expect(find_field('Comment').value).to have_text("@#{user.username}") end it 'does not wrap for emoji values' do - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys(":cartwheel_") - end + fill_in 'Comment', with: ':cartwheel_' - emoji_item = first('.tribute-container li', text: 'cartwheel_tone1', visible: true) + find_highlighted_tribute_autocomplete_menu.click - expect_to_wrap(false, emoji_item, note, 'cartwheel_tone1') + expect(find_field('Comment').value).to have_text('cartwheel_tone1') end it 'does not open autocomplete if there is no space before' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys("hello:#{user.username[0..2]}") - end + fill_in 'Comment', with: "hello:#{user.username[0..2]}" - expect(page).not_to have_selector('.tribute-container') + expect(page).not_to have_css('.tribute-container') end it 'autocompletes for quick actions' do - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys('/as') - wait_for_requests - note.native.send_keys(:tab) - end + fill_in 'Comment', with: '/as' + + find_highlighted_tribute_autocomplete_menu.click - expect(note.value).to have_text('/assign') + expect(find_field('Comment').value).to have_text('/assign') end end @@ -767,37 +620,33 @@ RSpec.describe 'GFM autocomplete', :js do it 'lists users who are currently not assigned to the issue when using /assign' do visit project_issue_path(project, issue_assignee) - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys('/assign ') - # The `/assign` ajax response might replace the one by `@` below causing a failed test - # so we need to wait for the `/assign` ajax request to finish first - wait_for_requests - note.native.send_keys('@') - wait_for_requests - end + note = find_field('Comment') + note.native.send_keys('/assign ') + # The `/assign` ajax response might replace the one by `@` below causing a failed test + # so we need to wait for the `/assign` ajax request to finish first + wait_for_requests + note.native.send_keys('@') + wait_for_requests - expect(find('.tribute-container ul', visible: true)).not_to have_content(user.username) - expect(find('.tribute-container ul', visible: true)).to have_content(unassigned_user.username) + expect(find_tribute_autocomplete_menu).not_to have_text(user.username) + expect(find_tribute_autocomplete_menu).to have_text(unassigned_user.username) end it 'lists users who are currently not assigned to the issue when using /assign on the second line' do visit project_issue_path(project, issue_assignee) - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys('/assign @user2') - note.native.send_keys(:enter) - note.native.send_keys('/assign ') - # The `/assign` ajax response might replace the one by `@` below causing a failed test - # so we need to wait for the `/assign` ajax request to finish first - wait_for_requests - note.native.send_keys('@') - wait_for_requests - end + note = find_field('Comment') + note.native.send_keys('/assign @user2') + note.native.send_keys(:enter) + note.native.send_keys('/assign ') + # The `/assign` ajax response might replace the one by `@` below causing a failed test + # so we need to wait for the `/assign` ajax request to finish first + wait_for_requests + note.native.send_keys('@') + wait_for_requests - expect(find('.tribute-container ul', visible: true)).not_to have_content(user.username) - expect(find('.tribute-container ul', visible: true)).to have_content(unassigned_user.username) + expect(find_tribute_autocomplete_menu).not_to have_text(user.username) + expect(find_tribute_autocomplete_menu).to have_text(unassigned_user.username) end end @@ -806,72 +655,65 @@ RSpec.describe 'GFM autocomplete', :js do label_xss_title = 'alert label <img src=x onerror="alert(\'Hello xss\');" a' create(:label, project: project, title: label_xss_title) - note = find('#note-body') - - # It should show all the labels on "~". - type(note, '~') + fill_in 'Comment', with: '~' wait_for_requests - expect(find('.tribute-container ul', visible: true).text).to have_content('alert label') + expect(find_tribute_autocomplete_menu).to have_text('alert label') end it 'allows colons when autocompleting scoped labels' do create(:label, project: project, title: 'scoped:label') - note = find('#note-body') - type(note, '~scoped:') + fill_in 'Comment', with: '~scoped:' wait_for_requests - expect(find('.tribute-container ul', visible: true).text).to have_content('scoped:label') + expect(find_tribute_autocomplete_menu).to have_text('scoped:label') end it 'allows colons when autocompleting scoped labels with double colons' do create(:label, project: project, title: 'scoped::label') - note = find('#note-body') - type(note, '~scoped::') + fill_in 'Comment', with: '~scoped::' wait_for_requests - expect(find('.tribute-container ul', visible: true).text).to have_content('scoped::label') + expect(find_tribute_autocomplete_menu).to have_text('scoped::label') end it 'autocompletes multi-word labels' do create(:label, project: project, title: 'Accepting merge requests') - note = find('#note-body') - type(note, '~Acceptingmerge') + fill_in 'Comment', with: '~Acceptingmerge' wait_for_requests - expect(find('.tribute-container ul', visible: true).text).to have_content('Accepting merge requests') + expect(find_tribute_autocomplete_menu).to have_text('Accepting merge requests') end it 'only autocompletes the latest label' do create(:label, project: project, title: 'documentation') create(:label, project: project, title: 'feature') - note = find('#note-body') - type(note, '~documentation foo bar ~feat') - note.native.send_keys(:right) + fill_in 'Comment', with: '~documentation foo bar ~feat' + # Invoke autocompletion + find_field('Comment').native.send_keys(:right) wait_for_requests - expect(find('.tribute-container ul', visible: true).text).to have_content('feature') - expect(find('.tribute-container ul', visible: true).text).not_to have_content('documentation') + expect(find_tribute_autocomplete_menu).to have_text('feature') + expect(find_tribute_autocomplete_menu).not_to have_text('documentation') end it 'does not autocomplete labels if no tilde is typed' do create(:label, project: project, title: 'documentation') - note = find('#note-body') - type(note, 'document') + fill_in 'Comment', with: 'document' wait_for_requests - expect(page).not_to have_selector('.tribute-container') + expect(page).not_to have_css('.tribute-container') end end @@ -881,7 +723,7 @@ RSpec.describe 'GFM autocomplete', :js do # This is meant to protect against this issue https://gitlab.com/gitlab-org/gitlab/-/issues/228729 it 'keeps autocomplete key listeners' do visit project_issue_path(project, issue) - note = find('#note-body') + note = find_field('Comment') start_comment_with_emoji(note, '.tribute-container li') @@ -897,17 +739,11 @@ RSpec.describe 'GFM autocomplete', :js do shared_examples 'autocomplete suggestions' do it 'suggests objects correctly' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys(object.class.reference_prefix) - end - - page.within '.tribute-container' do - expect(page).to have_content(object.title) + fill_in 'Comment', with: object.class.reference_prefix - find('ul li').click - end + find_tribute_autocomplete_menu.find('li').click - expect(find('.new-note #note-body').value).to include(expected_body) + expect(find_field('Comment').value).to have_text(expected_body) end end @@ -949,42 +785,6 @@ RSpec.describe 'GFM autocomplete', :js do private - def expect_to_wrap(should_wrap, item, note, value) - expect(item).to have_content(value) - expect(item).not_to have_content("\"#{value}\"") - - item.click - - if should_wrap - expect(note.value).to include("\"#{value}\"") - else - expect(note.value).not_to include("\"#{value}\"") - end - end - - def expect_labels(shown: nil, not_shown: nil) - page.within('.atwho-container') do - if shown - expect(page).to have_selector('.atwho-view li', count: shown.size) - shown.each { |label| expect(page).to have_content(label.title) } - end - - if not_shown - expect(page).not_to have_selector('.atwho-view li') unless shown - not_shown.each { |label| expect(page).not_to have_content(label.title) } - end - end - end - - # `note` is a textarea where the given text should be typed. - # We don't want to find it each time this function gets called. - def type(note, text) - page.within('.timeline-content-form') do - note.set('') - note.native.send_keys(text) - end - end - def start_comment_with_emoji(note, selector) note.native.send_keys('Hello :10') @@ -994,9 +794,7 @@ RSpec.describe 'GFM autocomplete', :js do end def start_and_cancel_discussion - click_button('Reply...') - - fill_in('note_note', with: 'Whoops!') + fill_in('Reply to comment', with: 'Whoops!') page.accept_alert 'Are you sure you want to cancel creating this comment?' do click_button('Cancel') @@ -1004,4 +802,20 @@ RSpec.describe 'GFM autocomplete', :js do wait_for_requests end + + def find_autocomplete_menu + find('.atwho-view ul', visible: true) + end + + def find_highlighted_autocomplete_item + find('.atwho-view li.cur', visible: true) + end + + def find_tribute_autocomplete_menu + find('.tribute-container ul', visible: true) + end + + def find_highlighted_tribute_autocomplete_menu + find('.tribute-container li.highlight', visible: true) + end end diff --git a/spec/features/issues/issue_state_spec.rb b/spec/features/issues/issue_state_spec.rb index 409f498798b..d5a115433aa 100644 --- a/spec/features/issues/issue_state_spec.rb +++ b/spec/features/issues/issue_state_spec.rb @@ -42,15 +42,9 @@ RSpec.describe 'issue state', :js do end describe 'when open', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/297348' do - let(:open_issue) { create(:issue, project: project) } - - it_behaves_like 'page with comment and close button', 'Close issue' do - def setup - visit project_issue_path(project, open_issue) - end - end - context 'when clicking the top `Close issue` button', :aggregate_failures do + let(:open_issue) { create(:issue, project: project) } + before do visit project_issue_path(project, open_issue) end @@ -59,8 +53,9 @@ RSpec.describe 'issue state', :js do end context 'when clicking the bottom `Close issue` button', :aggregate_failures do + let(:open_issue) { create(:issue, project: project) } + before do - stub_feature_flags(remove_comment_close_reopen: false) visit project_issue_path(project, open_issue) end @@ -69,15 +64,9 @@ RSpec.describe 'issue state', :js do end describe 'when closed', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/297201' do - let(:closed_issue) { create(:issue, project: project, state: 'closed') } - - it_behaves_like 'page with comment and close button', 'Reopen issue' do - def setup - visit project_issue_path(project, closed_issue) - end - end - context 'when clicking the top `Reopen issue` button', :aggregate_failures do + let(:closed_issue) { create(:issue, project: project, state: 'closed') } + before do visit project_issue_path(project, closed_issue) end @@ -86,8 +75,9 @@ RSpec.describe 'issue state', :js do end context 'when clicking the bottom `Reopen issue` button', :aggregate_failures do + let(:closed_issue) { create(:issue, project: project, state: 'closed') } + before do - stub_feature_flags(remove_comment_close_reopen: false) visit project_issue_path(project, closed_issue) end diff --git a/spec/features/issues/service_desk_spec.rb b/spec/features/issues/service_desk_spec.rb index 02804d84a21..75ea8c14f7f 100644 --- a/spec/features/issues/service_desk_spec.rb +++ b/spec/features/issues/service_desk_spec.rb @@ -49,8 +49,8 @@ RSpec.describe 'Service Desk Issue Tracker', :js do aggregate_failures do expect(page).to have_css('.empty-state') expect(page).to have_text('Use Service Desk to connect with your users') - expect(page).to have_link('Read more', href: help_page_path('user/project/service_desk')) - expect(page).not_to have_link('Turn on Service Desk') + expect(page).to have_link('Learn more.', href: help_page_path('user/project/service_desk')) + expect(page).not_to have_link('Enable Service Desk') expect(page).to have_content(project.service_desk_address) end end @@ -68,8 +68,8 @@ RSpec.describe 'Service Desk Issue Tracker', :js do aggregate_failures do expect(page).to have_css('.empty-state') expect(page).to have_text('Use Service Desk to connect with your users') - expect(page).to have_link('Read more', href: help_page_path('user/project/service_desk')) - expect(page).not_to have_link('Turn on Service Desk') + expect(page).to have_link('Learn more.', href: help_page_path('user/project/service_desk')) + expect(page).not_to have_link('Enable Service Desk') expect(page).not_to have_content(project.service_desk_address) end end @@ -91,8 +91,8 @@ RSpec.describe 'Service Desk Issue Tracker', :js do it 'displays the small info box, documentation, a button to configure service desk, and the address' do aggregate_failures do expect(page).to have_css('.non-empty-state') - expect(page).to have_link('Read more', href: help_page_path('user/project/service_desk')) - expect(page).not_to have_link('Turn on Service Desk') + expect(page).to have_link('Learn more.', href: help_page_path('user/project/service_desk')) + expect(page).not_to have_link('Enable Service Desk') expect(page).to have_content(project.service_desk_address) end end @@ -156,8 +156,8 @@ RSpec.describe 'Service Desk Issue Tracker', :js do aggregate_failures do expect(page).to have_css('.empty-state') expect(page).to have_text('Service Desk is not supported') - expect(page).to have_text('In order to enable Service Desk for your instance, you must first set up incoming email.') - expect(page).to have_link('More information', href: help_page_path('administration/incoming_email', anchor: 'set-it-up')) + expect(page).to have_text('To enable Service Desk on this instance, an instance administrator must first set up incoming email.') + expect(page).to have_link('Learn more.', href: help_page_path('administration/incoming_email', anchor: 'set-it-up')) end end end diff --git a/spec/features/issues/user_interacts_with_awards_spec.rb b/spec/features/issues/user_interacts_with_awards_spec.rb index fec603e466a..1c7bc5f239f 100644 --- a/spec/features/issues/user_interacts_with_awards_spec.rb +++ b/spec/features/issues/user_interacts_with_awards_spec.rb @@ -135,11 +135,9 @@ RSpec.describe 'User interacts with awards' do it 'allows adding a new emoji' do page.within('.note-actions') do - find('a.js-add-award').click - end - page.within('.emoji-menu-content') do - find('gl-emoji[data-name="8ball"]').click + find('.note-emoji-button').click end + find('gl-emoji[data-name="8ball"]').click wait_for_requests page.within('.note-awards') do @@ -157,7 +155,7 @@ RSpec.describe 'User interacts with awards' do end page.within('.note-actions') do - expect(page).not_to have_css('a.js-add-award') + expect(page).not_to have_css('.btn.js-add-award') end end diff --git a/spec/features/labels_hierarchy_spec.rb b/spec/features/labels_hierarchy_spec.rb index aeb42cc2edb..0a2f81986be 100644 --- a/spec/features/labels_hierarchy_spec.rb +++ b/spec/features/labels_hierarchy_spec.rb @@ -160,7 +160,7 @@ RSpec.describe 'Labels Hierarchy', :js do find('a.label-item', text: parent_group_label.title).click find('a.label-item', text: project_label_1.title).click - find('.btn-success').click + find('.btn-confirm').click expect(page.find('.issue-details h2.title')).to have_content('new created issue') expect(page).to have_selector('span.gl-label-text', text: grandparent_group_label.title) diff --git a/spec/features/markdown/markdown_spec.rb b/spec/features/markdown/markdown_spec.rb index 8e28f89f49e..e84b300a748 100644 --- a/spec/features/markdown/markdown_spec.rb +++ b/spec/features/markdown/markdown_spec.rb @@ -290,7 +290,7 @@ RSpec.describe 'GitLab Markdown', :aggregate_failures do path = 'images/example.jpg' gitaly_wiki_file = Gitlab::GitalyClient::WikiFile.new(path: path) - expect(@wiki).to receive(:find_file).with(path).and_return(Gitlab::Git::WikiFile.new(gitaly_wiki_file)) + expect(@wiki).to receive(:find_file).with(path, load_content: false).and_return(Gitlab::Git::WikiFile.new(gitaly_wiki_file)) allow(@wiki).to receive(:wiki_base_path) { '/namespace1/gitlabhq/wikis' } @html = markdown(@feat.raw_markdown, { pipeline: :wiki, wiki: @wiki, page_slug: @wiki_page.slug }) diff --git a/spec/features/markdown/math_spec.rb b/spec/features/markdown/math_spec.rb index e5fb9131ce0..441cff7045f 100644 --- a/spec/features/markdown/math_spec.rb +++ b/spec/features/markdown/math_spec.rb @@ -39,4 +39,20 @@ RSpec.describe 'Math rendering', :js do expect(page).to have_selector('.katex-html a', text: 'Gitlab') end end + + it 'renders lazy load button' do + description = <<~MATH + ```math + \Huge \sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}} + ``` + MATH + + issue = create(:issue, project: project, description: description) + + visit project_issue_path(project, issue) + + page.within '.description > .md' do + expect(page).to have_selector('.js-lazy-render-math') + end + end end diff --git a/spec/features/merge_request/batch_comments_spec.rb b/spec/features/merge_request/batch_comments_spec.rb index c8fc23bebf9..25f2707146d 100644 --- a/spec/features/merge_request/batch_comments_spec.rb +++ b/spec/features/merge_request/batch_comments_spec.rb @@ -223,7 +223,7 @@ end def write_reply_to_discussion(button_text: 'Start a review', text: 'Line is wrong', resolve: false, unresolve: false) page.within(first('.diff-files-holder .discussion-reply-holder')) do - click_button('Reply...') + find_field('Reply…', match: :first).click fill_in('note_note', with: text) diff --git a/spec/features/merge_request/user_closes_reopens_merge_request_state_spec.rb b/spec/features/merge_request/user_closes_reopens_merge_request_state_spec.rb index ab3ef7c1ac0..70951982c22 100644 --- a/spec/features/merge_request/user_closes_reopens_merge_request_state_spec.rb +++ b/spec/features/merge_request/user_closes_reopens_merge_request_state_spec.rb @@ -12,15 +12,9 @@ RSpec.describe 'User closes/reopens a merge request', :js, quarantine: 'https:// end describe 'when open' do - let(:open_merge_request) { create(:merge_request, source_project: project, target_project: project) } - - it_behaves_like 'page with comment and close button', 'Close merge request' do - def setup - visit merge_request_path(open_merge_request) - end - end - context 'when clicking the top `Close merge request` link', :aggregate_failures do + let(:open_merge_request) { create(:merge_request, source_project: project, target_project: project) } + before do visit merge_request_path(open_merge_request) end @@ -40,8 +34,9 @@ RSpec.describe 'User closes/reopens a merge request', :js, quarantine: 'https:// end context 'when clicking the bottom `Close merge request` button', :aggregate_failures do + let(:open_merge_request) { create(:merge_request, source_project: project, target_project: project) } + before do - stub_feature_flags(remove_comment_close_reopen: false) visit merge_request_path(open_merge_request) end @@ -61,22 +56,9 @@ RSpec.describe 'User closes/reopens a merge request', :js, quarantine: 'https:// end describe 'when closed' do - let(:closed_merge_request) { create(:merge_request, source_project: project, target_project: project, state: 'closed') } - - it_behaves_like 'page with comment and close button', 'Close merge request' do - def setup - visit merge_request_path(closed_merge_request) - - within '.detail-page-header' do - click_button 'Toggle dropdown' - click_link 'Reopen merge request' - end - - wait_for_requests - end - end - context 'when clicking the top `Reopen merge request` link', :aggregate_failures do + let(:closed_merge_request) { create(:merge_request, source_project: project, target_project: project, state: 'closed') } + before do visit merge_request_path(closed_merge_request) end @@ -96,8 +78,9 @@ RSpec.describe 'User closes/reopens a merge request', :js, quarantine: 'https:// end context 'when clicking the bottom `Reopen merge request` button', :aggregate_failures do + let(:closed_merge_request) { create(:merge_request, source_project: project, target_project: project, state: 'closed') } + before do - stub_feature_flags(remove_comment_close_reopen: false) visit merge_request_path(closed_merge_request) end diff --git a/spec/features/merge_request/user_posts_diff_notes_spec.rb b/spec/features/merge_request/user_posts_diff_notes_spec.rb index 794dfd7c8da..163ce10132e 100644 --- a/spec/features/merge_request/user_posts_diff_notes_spec.rb +++ b/spec/features/merge_request/user_posts_diff_notes_spec.rb @@ -192,7 +192,7 @@ RSpec.describe 'Merge request > User posts diff notes', :js do it 'adds as discussion' do should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]'), asset_form_reset: false) expect(page).to have_css('.notes_holder .note.note-discussion', count: 1) - expect(page).to have_button('Reply...') + expect(page).to have_field('Reply…') end end end diff --git a/spec/features/merge_request/user_posts_notes_spec.rb b/spec/features/merge_request/user_posts_notes_spec.rb index e629bc0dc53..3099a893dc2 100644 --- a/spec/features/merge_request/user_posts_notes_spec.rb +++ b/spec/features/merge_request/user_posts_notes_spec.rb @@ -44,7 +44,10 @@ RSpec.describe 'Merge request > User posts notes', :js do it 'has enable submit button, preview button and saves content to local storage' do page.within('.js-main-target-form') do - expect(page).not_to have_css('.js-comment-button[disabled]') + page.within('[data-testid="comment-button"]') do + expect(page).to have_css('.split-content-button') + expect(page).not_to have_css('.split-content-button[disabled]') + end expect(page).to have_css('.js-md-preview-button', visible: true) end diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb index b86586d53e2..caa04059469 100644 --- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb +++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb @@ -149,7 +149,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do it 'allows user to comment' do page.within '.diff-content' do - click_button 'Reply...' + find_field('Reply…').click find(".js-unresolve-checkbox").set false find('.js-note-text').set 'testing' @@ -179,7 +179,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do it 'allows user to comment & unresolve thread' do page.within '.diff-content' do - click_button 'Reply...' + find_field('Reply…').click find('.js-note-text').set 'testing' @@ -208,7 +208,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do it 'allows user to comment & resolve thread' do page.within '.diff-content' do - click_button 'Reply...' + find_field('Reply…').click find('.js-note-text').set 'testing' @@ -442,7 +442,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do it 'allows user to comment & resolve thread' do page.within '.diff-content' do - click_button 'Reply...' + find_field('Reply…').click find('.js-note-text').set 'testing' @@ -461,7 +461,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do page.within '.diff-content' do click_button 'Resolve thread' - click_button 'Reply...' + find_field('Reply…').click find('.js-note-text').set 'testing' diff --git a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb index d15d5b3bc73..90cdc28d1bd 100644 --- a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb +++ b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb @@ -37,7 +37,7 @@ RSpec.describe 'Merge request > User sees avatars on diff notes', :js do end it 'does not render avatars after commenting on discussion tab' do - click_button 'Reply...' + find_field('Reply…').click page.within('.js-discussion-note-form') do find('.note-textarea').native.send_keys('Test comment') @@ -132,7 +132,7 @@ RSpec.describe 'Merge request > User sees avatars on diff notes', :js do end it 'adds avatar when commenting' do - click_button 'Reply...' + find_field('Reply…', match: :first).click page.within '.js-discussion-note-form' do find('.js-note-text').native.send_keys('Test') @@ -151,7 +151,7 @@ RSpec.describe 'Merge request > User sees avatars on diff notes', :js do it 'adds multiple comments' do 3.times do - click_button 'Reply...' + find_field('Reply…', match: :first).click page.within '.js-discussion-note-form' do find('.js-note-text').native.send_keys('Test') diff --git a/spec/features/merge_request/user_sees_discussions_spec.rb b/spec/features/merge_request/user_sees_discussions_spec.rb index 289c861739f..d79763ba5e0 100644 --- a/spec/features/merge_request/user_sees_discussions_spec.rb +++ b/spec/features/merge_request/user_sees_discussions_spec.rb @@ -60,7 +60,7 @@ RSpec.describe 'Merge request > User sees threads', :js do it 'can be replied to' do within(".discussion[data-discussion-id='#{discussion_id}']") do - click_button 'Reply...' + find_field('Reply…').click fill_in 'note[note]', with: 'Test!' click_button 'Comment' diff --git a/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb index 708ce53b4fe..ad0e9b48903 100644 --- a/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb +++ b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb @@ -26,6 +26,7 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request', end before do + stub_feature_flags(new_pipelines_table: false) stub_application_setting(auto_devops_enabled: false) stub_ci_pipeline_yaml_file(YAML.dump(config)) project.add_maintainer(user) diff --git a/spec/features/merge_request/user_sees_merge_widget_spec.rb b/spec/features/merge_request/user_sees_merge_widget_spec.rb index 0854a8b9fb7..05fa5459e06 100644 --- a/spec/features/merge_request/user_sees_merge_widget_spec.rb +++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb @@ -651,7 +651,6 @@ RSpec.describe 'Merge request > User sees merge widget', :js do within(".js-report-section-container") do expect(page).to have_content('rspec found 1 failed out of 1 total test') expect(page).to have_content('junit found no changed test results out of 1 total test') - expect(page).not_to have_content('New') expect(page).to have_content('Test#sum when a is 1 and b is 3 returns summary') end end @@ -792,7 +791,6 @@ RSpec.describe 'Merge request > User sees merge widget', :js do within(".js-report-section-container") do expect(page).to have_content('rspec found 1 error out of 1 total test') expect(page).to have_content('junit found no changed test results out of 1 total test') - expect(page).not_to have_content('New') expect(page).to have_content('Test#sum when a is 4 and b is 4 returns summary') end end diff --git a/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb b/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb index 1ef6d2a1068..c0dc2ec3baf 100644 --- a/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb +++ b/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb @@ -9,166 +9,149 @@ RSpec.describe 'Merge request < User sees mini pipeline graph', :js do let(:pipeline) { create(:ci_empty_pipeline, project: project, ref: 'master', status: 'running', sha: project.commit.id) } let(:build) { create(:ci_build, pipeline: pipeline, stage: 'test') } - shared_examples 'mini pipeline renders' do |ci_mini_pipeline_gl_dropdown_enabled| - before do - build.run - build.trace.set('hello') - sign_in(user) - stub_feature_flags(ci_mini_pipeline_gl_dropdown: ci_mini_pipeline_gl_dropdown_enabled) - visit_merge_request - end + dropdown_selector = '[data-testid="mini-pipeline-graph-dropdown"]' - let_it_be(:dropdown_toggle_selector) do - if ci_mini_pipeline_gl_dropdown_enabled - '[data-testid="mini-pipeline-graph-dropdown"] .dropdown-toggle' - else - '[data-testid="mini-pipeline-graph-dropdown-toggle"]' - end - end + before do + build.run + build.trace.set('hello') + sign_in(user) + visit_merge_request + end - def visit_merge_request(format: :html, serializer: nil) - visit project_merge_request_path(project, merge_request, format: format, serializer: serializer) - end + def visit_merge_request(format: :html, serializer: nil) + visit project_merge_request_path(project, merge_request, format: format, serializer: serializer) + end - it 'displays a mini pipeline graph' do - expect(page).to have_selector('.mr-widget-pipeline-graph') - end + it 'displays a mini pipeline graph' do + expect(page).to have_selector('.mr-widget-pipeline-graph') + end - context 'as json' do - let(:artifacts_file1) { fixture_file_upload(File.join('spec/fixtures/banana_sample.gif'), 'image/gif') } - let(:artifacts_file2) { fixture_file_upload(File.join('spec/fixtures/dk.png'), 'image/png') } + context 'as json' do + let(:artifacts_file1) { fixture_file_upload(File.join('spec/fixtures/banana_sample.gif'), 'image/gif') } + let(:artifacts_file2) { fixture_file_upload(File.join('spec/fixtures/dk.png'), 'image/png') } - before do - job = create(:ci_build, :success, :trace_artifact, pipeline: pipeline) - create(:ci_job_artifact, :archive, file: artifacts_file1, job: job) - create(:ci_build, :manual, pipeline: pipeline, when: 'manual') - end + before do + job = create(:ci_build, :success, :trace_artifact, pipeline: pipeline) + create(:ci_job_artifact, :archive, file: artifacts_file1, job: job) + create(:ci_build, :manual, pipeline: pipeline, when: 'manual') + end - # TODO: https://gitlab.com/gitlab-org/gitlab-foss/issues/48034 - xit 'avoids repeated database queries' do - before = ActiveRecord::QueryRecorder.new { visit_merge_request(format: :json, serializer: 'widget') } + # TODO: https://gitlab.com/gitlab-org/gitlab-foss/issues/48034 + xit 'avoids repeated database queries' do + before = ActiveRecord::QueryRecorder.new { visit_merge_request(format: :json, serializer: 'widget') } - job = create(:ci_build, :success, :trace_artifact, pipeline: pipeline) - create(:ci_job_artifact, :archive, file: artifacts_file2, job: job) - create(:ci_build, :manual, pipeline: pipeline, when: 'manual') + job = create(:ci_build, :success, :trace_artifact, pipeline: pipeline) + create(:ci_job_artifact, :archive, file: artifacts_file2, job: job) + create(:ci_build, :manual, pipeline: pipeline, when: 'manual') - after = ActiveRecord::QueryRecorder.new { visit_merge_request(format: :json, serializer: 'widget') } + after = ActiveRecord::QueryRecorder.new { visit_merge_request(format: :json, serializer: 'widget') } - expect(before.count).to eq(after.count) - expect(before.cached_count).to eq(after.cached_count) - end + expect(before.count).to eq(after.count) + expect(before.cached_count).to eq(after.cached_count) end + end - describe 'build list toggle' do - let(:toggle) do - find(dropdown_toggle_selector) - first(dropdown_toggle_selector) - end + describe 'build list toggle' do + let(:toggle) do + find(dropdown_selector) + first(dropdown_selector) + end - # Status icon button styles should update as described in - # https://gitlab.com/gitlab-org/gitlab-foss/issues/42769 - it 'has unique styles for default, :hover, :active, and :focus states' do - default_background_color, default_foreground_color, default_box_shadow = get_toggle_colors(dropdown_toggle_selector) + # Status icon button styles should update as described in + # https://gitlab.com/gitlab-org/gitlab-foss/issues/42769 + it 'has unique styles for default, :hover, :active, and :focus states' do + default_background_color, default_foreground_color, default_box_shadow = get_toggle_colors(dropdown_selector) - toggle.hover - hover_background_color, hover_foreground_color, hover_box_shadow = get_toggle_colors(dropdown_toggle_selector) + toggle.hover + hover_background_color, hover_foreground_color, hover_box_shadow = get_toggle_colors(dropdown_selector) - page.driver.browser.action.click_and_hold(toggle.native).perform - active_background_color, active_foreground_color, active_box_shadow = get_toggle_colors(dropdown_toggle_selector) - page.driver.browser.action.release(toggle.native).perform + page.driver.browser.action.click_and_hold(toggle.native).perform + active_background_color, active_foreground_color, active_box_shadow = get_toggle_colors(dropdown_selector) + page.driver.browser.action.release(toggle.native).perform - page.driver.browser.action.click(toggle.native).move_by(100, 100).perform - focus_background_color, focus_foreground_color, focus_box_shadow = get_toggle_colors(dropdown_toggle_selector) + page.driver.browser.action.click(toggle.native).move_by(100, 100).perform + focus_background_color, focus_foreground_color, focus_box_shadow = get_toggle_colors(dropdown_selector) - expect(default_background_color).not_to eq(hover_background_color) - expect(hover_background_color).not_to eq(active_background_color) - expect(default_background_color).not_to eq(active_background_color) + expect(default_background_color).not_to eq(hover_background_color) + expect(hover_background_color).not_to eq(active_background_color) + expect(default_background_color).not_to eq(active_background_color) - expect(default_foreground_color).not_to eq(hover_foreground_color) - expect(hover_foreground_color).not_to eq(active_foreground_color) - expect(default_foreground_color).not_to eq(active_foreground_color) + expect(default_foreground_color).not_to eq(hover_foreground_color) + expect(hover_foreground_color).not_to eq(active_foreground_color) + expect(default_foreground_color).not_to eq(active_foreground_color) - expect(focus_background_color).to eq(hover_background_color) - expect(focus_foreground_color).to eq(hover_foreground_color) + expect(focus_background_color).to eq(hover_background_color) + expect(focus_foreground_color).to eq(hover_foreground_color) - expect(default_box_shadow).to eq('none') - expect(hover_box_shadow).to eq('none') - expect(active_box_shadow).not_to eq('none') - expect(focus_box_shadow).not_to eq('none') - end + expect(default_box_shadow).to eq('none') + expect(hover_box_shadow).to eq('none') + expect(active_box_shadow).not_to eq('none') + expect(focus_box_shadow).not_to eq('none') + end - it 'shows tooltip when hovered' do - toggle.hover + it 'shows tooltip when hovered' do + toggle.hover - expect(page).to have_selector('.tooltip') - end + expect(page).to have_selector('.tooltip') end + end - describe 'builds list menu' do - let(:toggle) do - find(dropdown_toggle_selector) - first(dropdown_toggle_selector) - end + describe 'builds list menu' do + let(:toggle) do + find(dropdown_selector) + first(dropdown_selector) + end - before do - toggle.click - wait_for_requests - end + before do + toggle.click + wait_for_requests + end - it 'pens when toggle is clicked' do - expect(toggle.find(:xpath, '..')).to have_selector('.mini-pipeline-graph-dropdown-menu') - end + it 'pens when toggle is clicked' do + expect(toggle.find(:xpath, '..')).to have_selector('.mini-pipeline-graph-dropdown-menu') + end - it 'closes when toggle is clicked again' do - toggle.click + it 'closes when toggle is clicked again' do + toggle.click - expect(toggle.find(:xpath, '..')).not_to have_selector('.mini-pipeline-graph-dropdown-menu') - end + expect(toggle.find(:xpath, '..')).not_to have_selector('.mini-pipeline-graph-dropdown-menu') + end - it 'closes when clicking somewhere else' do - find('body').click + it 'closes when clicking somewhere else' do + find('body').click - expect(toggle.find(:xpath, '..')).not_to have_selector('.mini-pipeline-graph-dropdown-menu') - end + expect(toggle.find(:xpath, '..')).not_to have_selector('.mini-pipeline-graph-dropdown-menu') + end - describe 'build list build item' do - let(:build_item) do - find('.mini-pipeline-graph-dropdown-item') - first('.mini-pipeline-graph-dropdown-item') - end + describe 'build list build item' do + let(:build_item) do + find('.mini-pipeline-graph-dropdown-item') + first('.mini-pipeline-graph-dropdown-item') + end - it 'visits the build page when clicked' do - build_item.click - find('.build-page') + it 'visits the build page when clicked' do + build_item.click + find('.build-page') - expect(current_path).to eql(project_job_path(project, build)) - end + expect(current_path).to eql(project_job_path(project, build)) + end - it 'shows tooltip when hovered' do - build_item.hover + it 'shows tooltip when hovered' do + build_item.hover - expect(page).to have_selector('.tooltip') - end + expect(page).to have_selector('.tooltip') end end end - context 'with ci_mini_pipeline_gl_dropdown disabled' do - it_behaves_like "mini pipeline renders", false - end - - context 'with ci_mini_pipeline_gl_dropdown enabled' do - it_behaves_like "mini pipeline renders", true - end - private def get_toggle_colors(selector) find(selector) [ - evaluate_script("$('#{selector}:visible').css('background-color');"), - evaluate_script("$('#{selector}:visible svg').css('fill');"), - evaluate_script("$('#{selector}:visible').css('box-shadow');") + evaluate_script("$('#{selector} button:visible').css('background-color');"), + evaluate_script("$('#{selector} button:visible svg').css('fill');"), + evaluate_script("$('#{selector} button:visible').css('box-shadow');") ] end end diff --git a/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb b/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb index 20c45a1d652..ea46ae06329 100644 --- a/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb +++ b/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb @@ -27,7 +27,7 @@ RSpec.describe 'Merge request > User sees notes from forked project', :js do expect(page).to have_content('A commit comment') page.within('.discussion-notes') do - find('.btn-text-field').click + find_field('Reply…').click scroll_to(page.find('#note_note', visible: false)) find('#note_note').send_keys('A reply comment') find('.js-comment-button').click diff --git a/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb index bf445de44ba..9850ca3f173 100644 --- a/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb +++ b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb @@ -121,14 +121,14 @@ RSpec.describe 'Merge request > User selects branches for new MR', :js do click_link 'Changes' - expect(page).to have_css('a.btn.active', text: 'Inline') - expect(page).not_to have_css('a.btn.active', text: 'Side-by-side') + expect(page).to have_css('a.btn.selected', text: 'Inline') + expect(page).not_to have_css('a.btn.selected', text: 'Side-by-side') click_link 'Side-by-side' within '.merge-request' do - expect(page).not_to have_css('a.btn.active', text: 'Inline') - expect(page).to have_css('a.btn.active', text: 'Side-by-side') + expect(page).not_to have_css('a.btn.selected', text: 'Inline') + expect(page).to have_css('a.btn.selected', text: 'Side-by-side') end end diff --git a/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb index bbeb91bbd19..dbc88d0cce2 100644 --- a/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb +++ b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb @@ -83,7 +83,7 @@ RSpec.describe 'User comments on a diff', :js do wait_for_requests - click_button 'Reply...' + find_field('Reply…', match: :first).click find('.js-suggestion-btn').click diff --git a/spec/features/merge_request/user_toggles_whitespace_changes_spec.rb b/spec/features/merge_request/user_toggles_whitespace_changes_spec.rb index 05f4c16ef60..b72ac071ecb 100644 --- a/spec/features/merge_request/user_toggles_whitespace_changes_spec.rb +++ b/spec/features/merge_request/user_toggles_whitespace_changes_spec.rb @@ -21,13 +21,13 @@ RSpec.describe 'Merge request > User toggles whitespace changes', :js do describe 'clicking "Hide whitespace changes" button' do it 'toggles the "Hide whitespace changes" button' do - find('#show-whitespace').click + find('[data-testid="show-whitespace"]').click visit diffs_project_merge_request_path(project, merge_request) find('.js-show-diff-settings').click - expect(find('#show-whitespace')).not_to be_checked + expect(find('[data-testid="show-whitespace"]')).not_to be_checked end end end diff --git a/spec/features/merge_requests/user_exports_as_csv_spec.rb b/spec/features/merge_requests/user_exports_as_csv_spec.rb index a86ff9d7335..725b8366d04 100644 --- a/spec/features/merge_requests/user_exports_as_csv_spec.rb +++ b/spec/features/merge_requests/user_exports_as_csv_spec.rb @@ -14,11 +14,13 @@ RSpec.describe 'Merge Requests > Exports as CSV', :js do subject { page.find('.nav-controls') } - it { is_expected.to have_button('Export as CSV') } + it { is_expected.to have_selector '[data-testid="export-csv-button"]' } context 'button is clicked' do before do - click_button('Export as CSV') + page.within('.nav-controls') do + find('[data-testid="export-csv-button"]').click + end end it 'shows a success message' do diff --git a/spec/features/participants_autocomplete_spec.rb b/spec/features/participants_autocomplete_spec.rb index d6f23b21d65..b22778012a8 100644 --- a/spec/features/participants_autocomplete_spec.rb +++ b/spec/features/participants_autocomplete_spec.rb @@ -85,6 +85,7 @@ RSpec.describe 'Member autocomplete', :js do let(:note) { create(:note_on_commit, project: project, commit_id: project.commit.id) } before do + allow(User).to receive(:find_by_any_email).and_call_original allow(User).to receive(:find_by_any_email) .with(noteable.author_email.downcase, confirmed: true).and_return(author) diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index 88bfc71cfbe..9e56ef087ae 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -138,4 +138,10 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do end end end + + it 'pushes `personal_access_tokens_scoped_to_projects` feature flag to the frontend' do + visit profile_personal_access_tokens_path + + expect(page).to have_pushed_frontend_feature_flags(personalAccessTokensScopedToProjects: true) + end end diff --git a/spec/features/profiles/user_visits_notifications_tab_spec.rb b/spec/features/profiles/user_visits_notifications_tab_spec.rb index 289fbff0404..939e791c75d 100644 --- a/spec/features/profiles/user_visits_notifications_tab_spec.rb +++ b/spec/features/profiles/user_visits_notifications_tab_spec.rb @@ -7,7 +7,6 @@ RSpec.describe 'User visits the notifications tab', :js do let(:user) { create(:user) } before do - stub_feature_flags(vue_notification_dropdown: false) project.add_maintainer(user) sign_in(user) visit(profile_notifications_path) @@ -16,17 +15,17 @@ RSpec.describe 'User visits the notifications tab', :js do it 'changes the project notifications setting' do expect(page).to have_content('Notifications') - first('#notifications-button').click - click_link('On mention') + first('[data-testid="notification-dropdown"]').click + click_button('On mention') - expect(page).to have_selector('#notifications-button', text: 'On mention') + expect(page).to have_selector('[data-testid="notification-dropdown"]', text: 'On mention') end context 'when project emails are disabled' do let(:project) { create(:project, emails_disabled: true) } it 'notification button is disabled' do - expect(page).to have_selector('.notifications-btn.disabled', visible: true) + expect(page).to have_selector('[data-testid="notification-dropdown"] .disabled') end end end diff --git a/spec/features/project_group_variables_spec.rb b/spec/features/project_group_variables_spec.rb index d8eba20ac18..fc482261fb1 100644 --- a/spec/features/project_group_variables_spec.rb +++ b/spec/features/project_group_variables_spec.rb @@ -57,7 +57,7 @@ RSpec.describe 'Project group variables', :js do wait_for_requests - page.within('.ci-variable-table') do + page.within('[data-testid="ci-variable-table"]') do expect(find('.js-ci-variable-row:nth-child(1) [data-label="Key"]').text).to eq(key1) end end diff --git a/spec/features/project_variables_spec.rb b/spec/features/project_variables_spec.rb index a7f94f38d85..327d8133411 100644 --- a/spec/features/project_variables_spec.rb +++ b/spec/features/project_variables_spec.rb @@ -24,7 +24,6 @@ RSpec.describe 'Project variables', :js do find('[data-qa-selector="ci_variable_key_field"] input').set('akey') find('#ci-variable-value').set('akey_value') find('[data-testid="environment-scope"]').click - find_button('clear').click find('[data-testid="ci-environment-search"]').set('review/*') find('[data-testid="create-wildcard-button"]').click @@ -33,7 +32,7 @@ RSpec.describe 'Project variables', :js do wait_for_requests - page.within('.ci-variable-table') do + page.within('[data-testid="ci-variable-table"]') do expect(find('.js-ci-variable-row:first-child [data-label="Environments"]').text).to eq('review/*') end end diff --git a/spec/features/projects/active_tabs_spec.rb b/spec/features/projects/active_tabs_spec.rb index 8001ce0f454..86fe59f003f 100644 --- a/spec/features/projects/active_tabs_spec.rb +++ b/spec/features/projects/active_tabs_spec.rb @@ -132,13 +132,13 @@ RSpec.describe 'Project active tab' do it_behaves_like 'page has active sub tab', _('Value Stream') end - context 'on project Analytics/"CI / CD"' do + context 'on project Analytics/"CI/CD"' do before do - click_tab(_('CI / CD')) + click_tab(_('CI/CD')) end it_behaves_like 'page has active tab', _('Analytics') - it_behaves_like 'page has active sub tab', _('CI / CD') + it_behaves_like 'page has active sub tab', _('CI/CD') end end end diff --git a/spec/features/projects/ci/lint_spec.rb b/spec/features/projects/ci/lint_spec.rb index ccffe25f45e..353c8558185 100644 --- a/spec/features/projects/ci/lint_spec.rb +++ b/spec/features/projects/ci/lint_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'CI Lint', :js do +RSpec.describe 'CI Lint', :js, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/297782' do include Spec::Support::Helpers::Features::EditorLiteSpecHelpers let(:project) { create(:project, :repository) } diff --git a/spec/features/projects/commit/mini_pipeline_graph_spec.rb b/spec/features/projects/commit/mini_pipeline_graph_spec.rb index cf9b86f16bb..7d206f76031 100644 --- a/spec/features/projects/commit/mini_pipeline_graph_spec.rb +++ b/spec/features/projects/commit/mini_pipeline_graph_spec.rb @@ -7,37 +7,55 @@ RSpec.describe 'Mini Pipeline Graph in Commit View', :js do context 'when commit has pipelines' do let(:pipeline) do - create(:ci_empty_pipeline, + create(:ci_pipeline, + status: :running, project: project, ref: project.default_branch, sha: project.commit.sha) end - let(:build) { create(:ci_build, pipeline: pipeline) } + let(:build) { create(:ci_build, pipeline: pipeline, status: :running) } - it 'display icon with status' do - build.run - visit project_commit_path(project, project.commit.id) + shared_examples 'shows ci icon and mini pipeline' do + before do + build.run + visit project_commit_path(project, project.commit.id) + end - expect(page).to have_selector('.ci-status-icon-running') - end + it 'display icon with status' do + expect(page).to have_selector('.ci-status-icon-running') + end - it 'displays a mini pipeline graph' do - build.run - visit project_commit_path(project, project.commit.id) + it 'displays a mini pipeline graph' do + expect(page).to have_selector('.mr-widget-pipeline-graph') - expect(page).to have_selector('.mr-widget-pipeline-graph') + first('.mini-pipeline-graph-dropdown-toggle').click - first('.mini-pipeline-graph-dropdown-toggle').click + wait_for_requests - wait_for_requests + page.within '.js-builds-dropdown-list' do + expect(page).to have_selector('.ci-status-icon-running') + expect(page).to have_content(build.stage) + end - page.within '.js-builds-dropdown-list' do - expect(page).to have_selector('.ci-status-icon-running') - expect(page).to have_content(build.stage) + build.drop + end + end + + context 'when ci_commit_pipeline_mini_graph_vue is disabled' do + before do + stub_feature_flags(ci_commit_pipeline_mini_graph_vue: false) + end + + it_behaves_like 'shows ci icon and mini pipeline' + end + + context 'when ci_commit_pipeline_mini_graph_vue is enabled' do + before do + stub_feature_flags(ci_commit_pipeline_mini_graph_vue: true) end - build.drop + it_behaves_like 'shows ci icon and mini pipeline' end end diff --git a/spec/features/projects/container_registry_spec.rb b/spec/features/projects/container_registry_spec.rb index d0ad6668c07..40d0260eafd 100644 --- a/spec/features/projects/container_registry_spec.rb +++ b/spec/features/projects/container_registry_spec.rb @@ -82,7 +82,13 @@ RSpec.describe 'Container Registry', :js do end it 'shows the image title' do - expect(page).to have_content 'my/image tags' + expect(page).to have_content 'my/image' + end + + it 'shows the image tags' do + expect(page).to have_content 'Image tags' + first_tag = first('[data-testid="name"]') + expect(first_tag).to have_content '1' end it 'user removes a specific tag from container repository' do diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb index 27167f95104..de7ff1c473d 100644 --- a/spec/features/projects/environments/environments_spec.rb +++ b/spec/features/projects/environments/environments_spec.rb @@ -429,37 +429,67 @@ RSpec.describe 'Environments page', :js do end describe 'environments folders' do - before do - create(:environment, :will_auto_stop, - project: project, - name: 'staging/review-1', - state: :available) - create(:environment, :will_auto_stop, - project: project, - name: 'staging/review-2', - state: :available) - end + describe 'available environments' do + before do + create(:environment, :will_auto_stop, + project: project, + name: 'staging/review-1', + state: :available) + create(:environment, :will_auto_stop, + project: project, + name: 'staging/review-2', + state: :available) + end - it 'users unfurls an environment folder' do - visit_environments(project) + it 'users unfurls an environment folder' do + visit_environments(project) - expect(page).not_to have_content 'review-1' - expect(page).not_to have_content 'review-2' - expect(page).to have_content 'staging 2' + expect(page).not_to have_content 'review-1' + expect(page).not_to have_content 'review-2' + expect(page).to have_content 'staging 2' - within('.folder-row') do - find('.folder-name', text: 'staging').click - end + within('.folder-row') do + find('.folder-name', text: 'staging').click + end - expect(page).to have_content 'review-1' - expect(page).to have_content 'review-2' - within('.ci-table') do - within('[data-qa-selector="environment_item"]', text: 'review-1') do - expect(find('.js-auto-stop').text).not_to be_empty + expect(page).to have_content 'review-1' + expect(page).to have_content 'review-2' + within('.ci-table') do + within('[data-qa-selector="environment_item"]', text: 'review-1') do + expect(find('.js-auto-stop').text).not_to be_empty + end + within('[data-qa-selector="environment_item"]', text: 'review-2') do + expect(find('.js-auto-stop').text).not_to be_empty + end end - within('[data-qa-selector="environment_item"]', text: 'review-2') do - expect(find('.js-auto-stop').text).not_to be_empty + end + end + + describe 'stopped environments' do + before do + create(:environment, :will_auto_stop, + project: project, + name: 'staging/review-1', + state: :stopped) + create(:environment, :will_auto_stop, + project: project, + name: 'staging/review-2', + state: :stopped) + end + + it 'users unfurls an environment folder' do + visit_environments(project, scope: 'stopped') + + expect(page).not_to have_content 'review-1' + expect(page).not_to have_content 'review-2' + expect(page).to have_content 'staging 2' + + within('.folder-row') do + find('.folder-name', text: 'staging').click end + + expect(page).to have_content 'review-1' + expect(page).to have_content 'review-2' end end end diff --git a/spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb b/spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb index f5941d0ff15..50fc7bb0753 100644 --- a/spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb +++ b/spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb @@ -104,7 +104,7 @@ RSpec.describe 'User sees feature flag list', :js do it 'shows empty page' do expect(page).to have_text 'Get started with feature flags' - expect(page).to have_selector('.btn-success', text: 'New feature flag') + expect(page).to have_selector('.btn-confirm', text: 'New feature flag') expect(page).to have_selector('[data-qa-selector="configure_feature_flags_button"]', text: 'Configure') end end diff --git a/spec/features/projects/files/gitlab_ci_syntax_yml_dropdown_spec.rb b/spec/features/projects/files/gitlab_ci_syntax_yml_dropdown_spec.rb index ca6f03472dd..cd796d45aba 100644 --- a/spec/features/projects/files/gitlab_ci_syntax_yml_dropdown_spec.rb +++ b/spec/features/projects/files/gitlab_ci_syntax_yml_dropdown_spec.rb @@ -5,11 +5,13 @@ require 'spec_helper' RSpec.describe 'Projects > Files > User wants to add a .gitlab-ci.yml file' do include Spec::Support::Helpers::Features::EditorLiteSpecHelpers + let_it_be(:namespace) { create(:namespace) } + let(:project) { create(:project, :repository, namespace: namespace) } + before do - project = create(:project, :repository) sign_in project.owner - stub_experiment(ci_syntax_templates: experiment_active) - stub_experiment_for_subject(ci_syntax_templates: in_experiment_group) + stub_experiment(ci_syntax_templates_b: experiment_active) + stub_experiment_for_subject(ci_syntax_templates_b: in_experiment_group) visit project_new_blob_path(project, 'master', file_name: '.gitlab-ci.yml') end @@ -23,35 +25,45 @@ RSpec.describe 'Projects > Files > User wants to add a .gitlab-ci.yml file' do end end - context 'when experiment is active and the user is in the control group' do + context 'when experiment is active' do let(:experiment_active) { true } - let(:in_experiment_group) { false } - it 'does not show the "Learn CI/CD syntax" template dropdown' do - expect(page).not_to have_css('.gitlab-ci-syntax-yml-selector') + context 'when the user is in the control group' do + let(:in_experiment_group) { false } + + it 'does not show the "Learn CI/CD syntax" template dropdown' do + expect(page).not_to have_css('.gitlab-ci-syntax-yml-selector') + end end - end - context 'when experiment is active and the user is in the experimental group' do - let(:experiment_active) { true } - let(:in_experiment_group) { true } + context 'when the user is in the experimental group' do + let(:in_experiment_group) { true } + + it 'allows the user to pick a "Learn CI/CD syntax" template from the dropdown', :js do + expect(page).to have_css('.gitlab-ci-syntax-yml-selector') - it 'allows the user to pick a "Learn CI/CD syntax" template from the dropdown', :js do - expect(page).to have_css('.gitlab-ci-syntax-yml-selector') + find('.js-gitlab-ci-syntax-yml-selector').click - find('.js-gitlab-ci-syntax-yml-selector').click + wait_for_requests - wait_for_requests + within '.gitlab-ci-syntax-yml-selector' do + find('.dropdown-input-field').set('Artifacts example') + find('.dropdown-content .is-focused', text: 'Artifacts example').click + end - within '.gitlab-ci-syntax-yml-selector' do - find('.dropdown-input-field').set('Artifacts example') - find('.dropdown-content .is-focused', text: 'Artifacts example').click + wait_for_requests + + expect(page).to have_css('.gitlab-ci-syntax-yml-selector .dropdown-toggle-text', text: 'Learn CI/CD syntax') + expect(editor_get_value).to have_content('You can use artifacts to pass data to jobs in later stages.') end - wait_for_requests + context 'when the group is created longer than 90 days ago' do + let(:namespace) { create(:namespace, created_at: 91.days.ago) } - expect(page).to have_css('.gitlab-ci-syntax-yml-selector .dropdown-toggle-text', text: 'Learn CI/CD syntax') - expect(editor_get_value).to have_content('You can use artifacts to pass data to jobs in later stages.') + it 'does not show the "Learn CI/CD syntax" template dropdown' do + expect(page).not_to have_css('.gitlab-ci-syntax-yml-selector') + end + end end end end diff --git a/spec/features/projects/fork_spec.rb b/spec/features/projects/fork_spec.rb index 8d0500f5e13..7abbd207b24 100644 --- a/spec/features/projects/fork_spec.rb +++ b/spec/features/projects/fork_spec.rb @@ -12,45 +12,27 @@ RSpec.describe 'Project fork' do sign_in(user) end - it 'allows user to fork project from the project page' do - visit project_path(project) - - expect(page).not_to have_css('a.disabled', text: 'Fork') - end - - context 'user has exceeded personal project limit' do - before do - user.update!(projects_limit: 0) - end - - it 'disables fork button on project page' do + shared_examples 'fork button on project page' do + it 'allows user to fork project from the project page' do visit project_path(project) - expect(page).to have_css('a.disabled', text: 'Fork') + expect(page).not_to have_css('a.disabled', text: 'Fork') end - context 'with a group to fork to' do - let!(:group) { create(:group).tap { |group| group.add_owner(user) } } - - it 'enables fork button on project page' do - visit project_path(project) - - expect(page).not_to have_css('a.disabled', text: 'Fork') + context 'user has exceeded personal project limit' do + before do + user.update!(projects_limit: 0) end - it 'allows user to fork only to the group on fork page', :js do - visit new_project_fork_path(project) - - to_personal_namespace = find('[data-qa-selector=fork_namespace_button].disabled') - to_group = find(".fork-groups button[data-qa-name=#{group.name}]") + it 'disables fork button on project page' do + visit project_path(project) - expect(to_personal_namespace).not_to be_nil - expect(to_group).not_to be_disabled + expect(page).to have_css('a.disabled', text: 'Fork') end end end - context 'forking enabled / disabled in project settings' do + shared_examples 'create fork page' do |fork_page_text| before do project.project_feature.update_attribute( :forking_access_level, forking_access_level) @@ -70,7 +52,7 @@ RSpec.describe 'Project fork' do visit new_project_fork_path(project) expect(page.status_code).to eq(200) - expect(page).to have_text(' Select a namespace to fork the project ') + expect(page).to have_text(fork_page_text) end end @@ -127,92 +109,88 @@ RSpec.describe 'Project fork' do visit new_project_fork_path(project) expect(page.status_code).to eq(200) - expect(page).to have_text(' Select a namespace to fork the project ') + expect(page).to have_text(fork_page_text) end end end end - it 'forks the project', :sidekiq_might_not_need_inline do - visit project_path(project) - - click_link 'Fork' + it_behaves_like 'fork button on project page' + it_behaves_like 'create fork page', 'Fork project' - page.within '.fork-thumbnail-container' do - click_link 'Select' + context 'with fork_project_form feature flag disabled' do + before do + stub_feature_flags(fork_project_form: false) + sign_in(user) end - expect(page).to have_content 'Forked from' + it_behaves_like 'fork button on project page' - visit project_path(project) + context 'user has exceeded personal project limit' do + before do + user.update!(projects_limit: 0) + end - expect(page).to have_content(/new merge request/i) + context 'with a group to fork to' do + let!(:group) { create(:group).tap { |group| group.add_owner(user) } } - page.within '.nav-sidebar' do - first(:link, 'Merge Requests').click - end + it 'allows user to fork only to the group on fork page', :js do + visit new_project_fork_path(project) - expect(page).to have_content(/new merge request/i) + to_personal_namespace = find('[data-qa-selector=fork_namespace_button].disabled') + to_group = find(".fork-groups button[data-qa-name=#{group.name}]") - page.within '#content-body' do - click_link('New merge request') + expect(to_personal_namespace).not_to be_nil + expect(to_group).not_to be_disabled + end + end end - expect(current_path).to have_content(/#{user.namespace.path}/i) - end + it_behaves_like 'create fork page', ' Select a namespace to fork the project ' - it 'shows avatars when Gravatar is disabled' do - stub_application_setting(gravatar_enabled: false) + it 'forks the project', :sidekiq_might_not_need_inline do + visit project_path(project) - visit project_path(project) + click_link 'Fork' - click_link 'Fork' + page.within '.fork-thumbnail-container' do + click_link 'Select' + end - page.within('.fork-thumbnail-container') do - expect(page).to have_css('div.identicon') - end - end + expect(page).to have_content 'Forked from' - it 'shows the forked project on the list' do - visit project_path(project) + visit project_path(project) - click_link 'Fork' + expect(page).to have_content(/new merge request/i) - page.within '.fork-thumbnail-container' do - click_link 'Select' - end + page.within '.nav-sidebar' do + first(:link, 'Merge Requests').click + end - visit project_forks_path(project) + expect(page).to have_content(/new merge request/i) - forked_project = user.fork_of(project.reload) + page.within '#content-body' do + click_link('New merge request') + end - page.within('.js-projects-list-holder') do - expect(page).to have_content("#{forked_project.namespace.human_name} / #{forked_project.name}") + expect(current_path).to have_content(/#{user.namespace.path}/i) end - forked_project.update!(path: 'test-crappy-path') - - visit project_forks_path(project) + it 'shows avatars when Gravatar is disabled' do + stub_application_setting(gravatar_enabled: false) - page.within('.js-projects-list-holder') do - expect(page).to have_content("#{forked_project.namespace.human_name} / #{forked_project.name}") - end - end + visit project_path(project) - context 'when the project is private' do - let(:project) { create(:project, :repository) } - let(:another_user) { create(:user, name: 'Mike') } + click_link 'Fork' - before do - project.add_reporter(user) - project.add_reporter(another_user) + page.within('.fork-thumbnail-container') do + expect(page).to have_css('div.identicon') + end end - it 'renders private forks of the project' do + it 'shows the forked project on the list' do visit project_path(project) - another_project_fork = Projects::ForkService.new(project, another_user).execute - click_link 'Fork' page.within '.fork-thumbnail-container' do @@ -221,79 +199,117 @@ RSpec.describe 'Project fork' do visit project_forks_path(project) + forked_project = user.fork_of(project.reload) + page.within('.js-projects-list-holder') do - user_project_fork = user.fork_of(project.reload) - expect(page).to have_content("#{user_project_fork.namespace.human_name} / #{user_project_fork.name}") + expect(page).to have_content("#{forked_project.namespace.human_name} / #{forked_project.name}") end - expect(page).not_to have_content("#{another_project_fork.namespace.human_name} / #{another_project_fork.name}") - end - end + forked_project.update!(path: 'test-crappy-path') - context 'when the user already forked the project' do - before do - create(:project, :repository, name: project.name, namespace: user.namespace) - end + visit project_forks_path(project) - it 'renders error' do - visit project_path(project) + page.within('.js-projects-list-holder') do + expect(page).to have_content("#{forked_project.namespace.human_name} / #{forked_project.name}") + end + end - click_link 'Fork' + context 'when the project is private' do + let(:project) { create(:project, :repository) } + let(:another_user) { create(:user, name: 'Mike') } - page.within '.fork-thumbnail-container' do - click_link 'Select' + before do + project.add_reporter(user) + project.add_reporter(another_user) end - expect(page).to have_content "Name has already been taken" - end - end + it 'renders private forks of the project' do + visit project_path(project) - context 'maintainer in group' do - let(:group) { create(:group) } + another_project_fork = Projects::ForkService.new(project, another_user).execute - before do - group.add_maintainer(user) - end + click_link 'Fork' - it 'allows user to fork project to group or to user namespace', :js do - visit project_path(project) - wait_for_requests + page.within '.fork-thumbnail-container' do + click_link 'Select' + end - expect(page).not_to have_css('a.disabled', text: 'Fork') + visit project_forks_path(project) - click_link 'Fork' + page.within('.js-projects-list-holder') do + user_project_fork = user.fork_of(project.reload) + expect(page).to have_content("#{user_project_fork.namespace.human_name} / #{user_project_fork.name}") + end - expect(page).to have_css('.fork-thumbnail') - expect(page).to have_css('.group-row') - expect(page).not_to have_css('.fork-thumbnail.disabled') + expect(page).not_to have_content("#{another_project_fork.namespace.human_name} / #{another_project_fork.name}") + end end - it 'allows user to fork project to group and not user when exceeded project limit', :js do - user.projects_limit = 0 - user.save! + context 'when the user already forked the project' do + before do + create(:project, :repository, name: project.name, namespace: user.namespace) + end - visit project_path(project) - wait_for_requests + it 'renders error' do + visit project_path(project) - expect(page).not_to have_css('a.disabled', text: 'Fork') + click_link 'Fork' - click_link 'Fork' + page.within '.fork-thumbnail-container' do + click_link 'Select' + end - expect(page).to have_css('.fork-thumbnail.disabled') - expect(page).to have_css('.group-row') + expect(page).to have_content "Name has already been taken" + end end - it 'links to the fork if the project was already forked within that namespace', :sidekiq_might_not_need_inline, :js do - forked_project = fork_project(project, user, namespace: group, repository: true) + context 'maintainer in group' do + let(:group) { create(:group) } + + before do + group.add_maintainer(user) + end + + it 'allows user to fork project to group or to user namespace', :js do + visit project_path(project) + wait_for_requests + + expect(page).not_to have_css('a.disabled', text: 'Fork') + + click_link 'Fork' + + expect(page).to have_css('.fork-thumbnail') + expect(page).to have_css('.group-row') + expect(page).not_to have_css('.fork-thumbnail.disabled') + end + + it 'allows user to fork project to group and not user when exceeded project limit', :js do + user.projects_limit = 0 + user.save! + + visit project_path(project) + wait_for_requests + + expect(page).not_to have_css('a.disabled', text: 'Fork') - visit new_project_fork_path(project) - wait_for_requests + click_link 'Fork' - expect(page).to have_css('.group-row a.btn', text: 'Go to fork') + expect(page).to have_css('.fork-thumbnail.disabled') + expect(page).to have_css('.group-row') + end + + it 'links to the fork if the project was already forked within that namespace', :sidekiq_might_not_need_inline, :js do + forked_project = fork_project(project, user, namespace: group, repository: true) + + visit new_project_fork_path(project) + wait_for_requests + + expect(page).to have_css('.group-row a.btn', text: 'Go to fork') - click_link 'Go to fork' + click_link 'Go to fork' - expect(current_path).to eq(project_path(forked_project)) + expect(current_path).to eq(project_path(forked_project)) + end end end end diff --git a/spec/features/projects/members/anonymous_user_sees_members_spec.rb b/spec/features/projects/members/anonymous_user_sees_members_spec.rb index d710ecf6c88..6b92581d704 100644 --- a/spec/features/projects/members/anonymous_user_sees_members_spec.rb +++ b/spec/features/projects/members/anonymous_user_sees_members_spec.rb @@ -14,25 +14,9 @@ RSpec.describe 'Projects > Members > Anonymous user sees members' do create(:project_group_link, project: project, group: group) end - context 'when `vue_project_members_list` feature flag is enabled', :js do - it "anonymous user visits the project's members page and sees the list of members" do - visit project_project_members_path(project) + it "anonymous user visits the project's members page and sees the list of members", :js do + visit project_project_members_path(project) - expect(find_member_row(user)).to have_content(user.name) - end - end - - context 'when `vue_project_members_list` feature flag is disabled' do - before do - stub_feature_flags(vue_project_members_list: false) - end - - it "anonymous user visits the project's members page and sees the list of members" do - visit project_project_members_path(project) - - expect(current_path).to eq( - project_project_members_path(project)) - expect(page).to have_content(user.name) - end + expect(find_member_row(user)).to have_content(user.name) end end diff --git a/spec/features/projects/members/group_members_spec.rb b/spec/features/projects/members/group_members_spec.rb index 1abd00421ec..94ce18fef93 100644 --- a/spec/features/projects/members/group_members_spec.rb +++ b/spec/features/projects/members/group_members_spec.rb @@ -20,218 +20,96 @@ RSpec.describe 'Projects members', :js do sign_in(user) end - context 'when `vue_project_members_list` feature flag is enabled' do - context 'with a group invitee' do - before do - group_invitee - visit project_project_members_path(project) - end - - it 'does not appear in the project members page' do - expect(members_table).not_to have_content('test2@abc.com') - end + context 'with a group invitee' do + before do + group_invitee + visit project_project_members_path(project) end - context 'with a group' do - it 'shows group and project members by default' do - visit project_project_members_path(project) - - expect(members_table).to have_content(developer.name) - expect(members_table).to have_content(user.name) - expect(members_table).to have_content(group.name) - end - - it 'shows project members only if requested' do - visit project_project_members_path(project, with_inherited_permissions: 'exclude') - - expect(members_table).to have_content(developer.name) - expect(members_table).not_to have_content(user.name) - expect(members_table).not_to have_content(group.name) - end + it 'does not appear in the project members page' do + expect(members_table).not_to have_content('test2@abc.com') + end + end - it 'shows group members only if requested' do - visit project_project_members_path(project, with_inherited_permissions: 'only') + context 'with a group' do + it 'shows group and project members by default' do + visit project_project_members_path(project) - expect(members_table).not_to have_content(developer.name) - expect(members_table).to have_content(user.name) - expect(members_table).to have_content(group.name) - end + expect(members_table).to have_content(developer.name) + expect(members_table).to have_content(user.name) + expect(members_table).to have_content(group.name) end - context 'with a group, a project invitee, and a project requester' do - before do - group.request_access(group_requester) - project.request_access(project_requester) - group_invitee - project_invitee - visit project_project_members_path(project) - end - - it 'shows the group owner' do - expect(members_table).to have_content(user.name) - expect(members_table).to have_content(group.name) - end - - it 'shows the project developer' do - expect(members_table).to have_content(developer.name) - end - - it 'shows the project invitee' do - click_link 'Invited' - - expect(members_table).to have_content('test1@abc.com') - expect(members_table).not_to have_content('test2@abc.com') - end - - it 'shows the project requester' do - click_link 'Access requests' - - expect(members_table).to have_content(project_requester.name) - expect(members_table).not_to have_content(group_requester.name) - end - end + it 'shows project members only if requested' do + visit project_project_members_path(project, with_inherited_permissions: 'exclude') - context 'with a group requester' do - before do - stub_feature_flags(invite_members_group_modal: false) - group.request_access(group_requester) - visit project_project_members_path(project) - end - - it 'does not appear in the project members page' do - expect(page).not_to have_link('Access requests') - expect(members_table).not_to have_content(group_requester.name) - end + expect(members_table).to have_content(developer.name) + expect(members_table).not_to have_content(user.name) + expect(members_table).not_to have_content(group.name) end - context 'showing status of members' do - it 'shows the status' do - create(:user_status, user: user, emoji: 'smirk', message: 'Authoring this object') + it 'shows group members only if requested' do + visit project_project_members_path(project, with_inherited_permissions: 'only') - visit project_project_members_path(project) - - expect(first_row).to have_selector('gl-emoji[data-name="smirk"]') - end + expect(members_table).not_to have_content(developer.name) + expect(members_table).to have_content(user.name) + expect(members_table).to have_content(group.name) end end - context 'when `vue_project_members_list` feature flag is disabled' do + context 'with a group, a project invitee, and a project requester' do before do - stub_feature_flags(vue_project_members_list: false) + group.request_access(group_requester) + project.request_access(project_requester) + group_invitee + project_invitee + visit project_project_members_path(project) end - context 'with a group invitee' do - before do - group_invitee - visit project_project_members_path(project) - end - - it 'does not appear in the project members page' do - page.within first('.content-list') do - expect(page).not_to have_content('test2@abc.com') - end - end + it 'shows the group owner' do + expect(members_table).to have_content(user.name) + expect(members_table).to have_content(group.name) end - context 'with a group' do - it 'shows group and project members by default' do - visit project_project_members_path(project) - - page.within first('.content-list') do - expect(page).to have_content(developer.name) - - expect(page).to have_content(user.name) - expect(page).to have_content(group.name) - end - end - - it 'shows project members only if requested' do - visit project_project_members_path(project, with_inherited_permissions: 'exclude') - - page.within first('.content-list') do - expect(page).to have_content(developer.name) + it 'shows the project developer' do + expect(members_table).to have_content(developer.name) + end - expect(page).not_to have_content(user.name) - expect(page).not_to have_content(group.name) - end - end + it 'shows the project invitee' do + click_link 'Invited' - it 'shows group members only if requested' do - visit project_project_members_path(project, with_inherited_permissions: 'only') + expect(members_table).to have_content('test1@abc.com') + expect(members_table).not_to have_content('test2@abc.com') + end - page.within first('.content-list') do - expect(page).not_to have_content(developer.name) + it 'shows the project requester' do + click_link 'Access requests' - expect(page).to have_content(user.name) - expect(page).to have_content(group.name) - end - end + expect(members_table).to have_content(project_requester.name) + expect(members_table).not_to have_content(group_requester.name) end + end - context 'with a group, a project invitee, and a project requester' do - before do - group.request_access(group_requester) - project.request_access(project_requester) - group_invitee - project_invitee - visit project_project_members_path(project) - end - - it 'shows the group owner' do - page.within first('.content-list') do - # Group owner - expect(page).to have_content(user.name) - expect(page).to have_content(group.name) - end - end - - it 'shows the project developer' do - page.within first('.content-list') do - # Project developer - expect(page).to have_content(developer.name) - end - end - - it 'shows the project invitee' do - click_link 'Invited' - - page.within first('.content-list') do - expect(page).to have_content('test1@abc.com') - expect(page).not_to have_content('test2@abc.com') - end - end - - it 'shows the project requester' do - click_link 'Access requests' - - page.within first('.content-list') do - expect(page).to have_content(project_requester.name) - expect(page).not_to have_content(group_requester.name) - end - end + context 'with a group requester' do + before do + stub_feature_flags(invite_members_group_modal: false) + group.request_access(group_requester) + visit project_project_members_path(project) end - context 'with a group requester' do - before do - stub_feature_flags(invite_members_group_modal: false) - group.request_access(group_requester) - visit project_project_members_path(project) - end - - it 'does not appear in the project members page' do - expect(page).not_to have_link('Access requests') - page.within first('.content-list') do - expect(page).not_to have_content(group_requester.name) - end - end + it 'does not appear in the project members page' do + expect(page).not_to have_link('Access requests') + expect(members_table).not_to have_content(group_requester.name) end + end + + context 'showing status of members' do + it 'shows the status' do + create(:user_status, user: user, emoji: 'smirk', message: 'Authoring this object') - context 'showing status of members' do - it_behaves_like 'showing user status' do - let(:user_with_status) { developer } + visit project_project_members_path(project) - subject { visit project_project_members_path(project) } - end + expect(first_row).to have_selector('gl-emoji[data-name="smirk"]') end end end diff --git a/spec/features/projects/members/groups_with_access_list_spec.rb b/spec/features/projects/members/groups_with_access_list_spec.rb index 9d087dfd5f6..6a1d26983b5 100644 --- a/spec/features/projects/members/groups_with_access_list_spec.rb +++ b/spec/features/projects/members/groups_with_access_list_spec.rb @@ -17,172 +17,80 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do project.add_maintainer(user) sign_in(user) - end - - context 'when `vue_project_members_list` feature flag is enabled' do - before do - visit project_project_members_path(project) - click_groups_tab - end - - it 'updates group access level' do - click_button group_link.human_access - click_button 'Guest' - - wait_for_requests - - visit project_project_members_path(project) - click_groups_tab - - expect(find_group_row(group)).to have_content('Guest') - end + visit project_project_members_path(project) + click_groups_tab + end - it 'updates expiry date' do - page.within find_group_row(group) do - fill_in 'Expiration date', with: 5.days.from_now.to_date - find_field('Expiration date').native.send_keys :enter + it 'updates group access level' do + click_button group_link.human_access + click_button 'Guest' - wait_for_requests + wait_for_requests - expect(page).to have_content(/in \d days/) - end - end + visit project_project_members_path(project) - context 'when link has expiry date set' do - let(:additional_link_attrs) { { expires_at: 5.days.from_now.to_date } } + click_groups_tab - it 'clears expiry date' do - page.within find_group_row(group) do - expect(page).to have_content(/in \d days/) + expect(find_group_row(group)).to have_content('Guest') + end - find('[data-testid="clear-button"]').click + it 'updates expiry date' do + page.within find_group_row(group) do + fill_in 'Expiration date', with: 5.days.from_now.to_date + find_field('Expiration date').native.send_keys :enter - wait_for_requests + wait_for_requests - expect(page).to have_content('No expiration set') - end - end + expect(page).to have_content(/in \d days/) end + end - it 'deletes group link' do - expect(page).to have_content(group.full_name) + context 'when link has expiry date set' do + let(:additional_link_attrs) { { expires_at: 5.days.from_now.to_date } } + it 'clears expiry date' do page.within find_group_row(group) do - click_button 'Remove group' - end - - page.within('[role="dialog"]') do - click_button('Remove group') - end - - expect(page).not_to have_content(group.full_name) - end - - context 'search in existing members' do - it 'finds no results' do - fill_in_filtered_search 'Search groups', with: 'testing 123' - - click_groups_tab - - expect(page).not_to have_content(group.full_name) - end + expect(page).to have_content(/in \d days/) - it 'finds results' do - fill_in_filtered_search 'Search groups', with: group.full_name + find('[data-testid="clear-button"]').click - click_groups_tab + wait_for_requests - expect(members_table).to have_content(group.full_name) + expect(page).to have_content('No expiration set') end end end - context 'when `vue_project_members_list` feature flag is disabled' do - before do - stub_feature_flags(vue_project_members_list: false) - - visit project_project_members_path(project) - click_groups_tab - end - - it 'updates group access level' do - click_button group_link.human_access - - page.within '.dropdown-menu' do - click_link 'Guest' - end - - wait_for_requests - - visit project_project_members_path(project) - - click_groups_tab + it 'deletes group link' do + expect(page).to have_content(group.full_name) - expect(first('.group_member')).to have_content('Guest') + page.within find_group_row(group) do + click_button 'Remove group' end - it 'updates expiry date' do - expires_at_field = "member_expires_at_#{group.id}" - fill_in expires_at_field, with: 3.days.from_now.to_date - - find_field(expires_at_field).native.send_keys :enter - wait_for_requests - - page.within(find('li.group_member')) do - expect(page).to have_content('Expires in 3 days') - end + page.within('[role="dialog"]') do + click_button('Remove group') end - context 'when link has expiry date set' do - let(:additional_link_attrs) { { expires_at: 3.days.from_now.to_date } } - - it 'clears expiry date' do - page.within(find('li.group_member')) do - expect(page).to have_content('Expires in 3 days') - - page.within(find('.js-edit-member-form')) do - find('.js-clear-input').click - end - - wait_for_requests + expect(page).not_to have_content(group.full_name) + end - expect(page).not_to have_content('Expires in') - end - end - end + context 'search in existing members' do + it 'finds no results' do + fill_in_filtered_search 'Search groups', with: 'testing 123' - it 'deletes group link' do - page.within(first('.group_member')) do - accept_confirm { find('.btn-danger').click } - end - wait_for_requests + click_groups_tab - expect(page).not_to have_selector('.group_member') + expect(page).not_to have_content(group.full_name) end - context 'search in existing members' do - it 'finds no results' do - page.within '.user-search-form' do - fill_in 'search_groups', with: 'testing 123' - find('.user-search-btn').click - end - - click_groups_tab - - expect(page).not_to have_selector('.group_member') - end - - it 'finds results' do - page.within '.user-search-form' do - fill_in 'search_groups', with: group.name - find('.user-search-btn').click - end + it 'finds results' do + fill_in_filtered_search 'Search groups', with: group.full_name - click_groups_tab + click_groups_tab - expect(page).to have_selector('.group_member', count: 1) - end + expect(members_table).to have_content(group.full_name) end end diff --git a/spec/features/projects/members/invite_group_spec.rb b/spec/features/projects/members/invite_group_spec.rb index f0d115fef1d..83ba2533a73 100644 --- a/spec/features/projects/members/invite_group_spec.rb +++ b/spec/features/projects/members/invite_group_spec.rb @@ -41,46 +41,20 @@ RSpec.describe 'Project > Members > Invite group', :js do context 'when the group has "Share with group lock" disabled' do it_behaves_like 'the project can be shared with groups' - context 'when `vue_project_members_list` feature flag is enabled' do - it 'the project can be shared with another group' do - visit project_project_members_path(project) + it 'the project can be shared with another group' do + visit project_project_members_path(project) - expect(page).not_to have_link 'Groups' + expect(page).not_to have_link 'Groups' - click_on 'invite-group-tab' + click_on 'invite-group-tab' - select2 group_to_share_with.id, from: '#link_group_id' - page.find('body').click - find('.btn-success').click + select2 group_to_share_with.id, from: '#link_group_id' + page.find('body').click + find('.btn-confirm').click - click_link 'Groups' + click_link 'Groups' - expect(members_table).to have_content(group_to_share_with.name) - end - end - - context 'when `vue_project_members_list` feature flag is disabled' do - before do - stub_feature_flags(vue_project_members_list: false) - end - - it 'the project can be shared with another group' do - visit project_project_members_path(project) - - expect(page).not_to have_link 'Groups' - - click_on 'invite-group-tab' - - select2 group_to_share_with.id, from: '#link_group_id' - page.find('body').click - find('.btn-success').click - - click_link 'Groups' - - page.within('[data-testid="project-member-groups"]') do - expect(page).to have_content(group_to_share_with.name) - end - end + expect(members_table).to have_content(group_to_share_with.name) end end @@ -159,36 +133,15 @@ RSpec.describe 'Project > Members > Invite group', :js do fill_in 'expires_at_groups', with: 5.days.from_now.strftime('%Y-%m-%d') click_on 'invite-group-tab' - find('.btn-success').click - end - - context 'when `vue_project_members_list` feature flag is enabled' do - it 'the group link shows the expiration time with a warning class' do - setup - click_link 'Groups' - - expect(find_group_row(group)).to have_content(/in \d days/) - expect(find_group_row(group)).to have_selector('.gl-text-orange-500') - end + find('.btn-confirm').click end - context 'when `vue_project_members_list` feature flag is disabled' do - before do - stub_feature_flags(vue_project_members_list: false) - end - - it 'the group link shows the expiration time with a warning class' do - setup - click_link 'Groups' + it 'the group link shows the expiration time with a warning class' do + setup + click_link 'Groups' - page.within('[data-testid="project-member-groups"]') do - # Using distance_of_time_in_words_to_now because it is not the same as - # subtraction, and this way avoids time zone issues as well - expires_in_text = distance_of_time_in_words_to_now(project.project_group_links.first.expires_at) - expect(page).to have_content(expires_in_text) - expect(page).to have_selector('.text-warning') - end - end + expect(find_group_row(group)).to have_content(/in \d days/) + expect(find_group_row(group)).to have_selector('.gl-text-orange-500') end end diff --git a/spec/features/projects/members/list_spec.rb b/spec/features/projects/members/list_spec.rb index b0fe5b9c48a..0830585da9b 100644 --- a/spec/features/projects/members/list_spec.rb +++ b/spec/features/projects/members/list_spec.rb @@ -2,232 +2,192 @@ require 'spec_helper' -RSpec.describe 'Project members list' do +RSpec.describe 'Project members list', :js do include Select2Helper + include Spec::Support::Helpers::Features::MembersHelpers let(:user1) { create(:user, name: 'John Doe') } let(:user2) { create(:user, name: 'Mary Jane') } let(:group) { create(:group) } - let(:project) { create(:project, namespace: group) } + let(:project) { create(:project, :internal, namespace: group) } before do - stub_feature_flags(invite_members_group_modal: false) + stub_feature_flags(invite_members_group_modal: true) sign_in(user1) group.add_owner(user1) end - context 'when `vue_project_members_list` feature flag is enabled', :js do - include Spec::Support::Helpers::Features::MembersHelpers + it 'show members from project and group' do + project.add_developer(user2) - it 'pushes `vue_project_members_list` feature flag to the frontend' do - visit_members_page - - expect(page).to have_pushed_frontend_feature_flags(vueProjectMembersList: true) - end + visit_members_page - it 'show members from project and group' do - project.add_developer(user2) - - visit_members_page - - expect(first_row).to have_content(user1.name) - expect(second_row).to have_content(user2.name) - end + expect(first_row).to have_content(user1.name) + expect(second_row).to have_content(user2.name) + end - it 'show user once if member of both group and project' do - project.add_developer(user1) + it 'show user once if member of both group and project' do + project.add_developer(user1) - visit_members_page + visit_members_page - expect(first_row).to have_content(user1.name) - expect(second_row).to be_blank - end + expect(first_row).to have_content(user1.name) + expect(second_row).to be_blank + end - it 'update user access level', :js do - project.add_developer(user2) + it 'update user access level' do + project.add_developer(user2) - visit_members_page + visit_members_page - page.within find_member_row(user2) do - click_button('Developer') - click_button('Reporter') + page.within find_member_row(user2) do + click_button('Developer') + click_button('Reporter') - expect(page).to have_button('Reporter') - end + expect(page).to have_button('Reporter') end + end - it 'add user to project', :js do - visit_members_page + it 'add user to project' do + visit_members_page - add_user(user2.id, 'Reporter') + add_user(user2.name, 'Reporter') - page.within find_member_row(user2) do - expect(page).to have_button('Reporter') - end + page.within find_member_row(user2) do + expect(page).to have_button('Reporter') end + end - it 'remove user from project', :js do - other_user = create(:user) - project.add_developer(other_user) - - visit_members_page - - # Open modal - page.within find_member_row(other_user) do - click_button 'Remove member' - end + it 'uses ProjectMember access_level_roles for the invite members modal access option' do + visit_members_page - page.within('[role="dialog"]') do - expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests' - click_button('Remove member') - end + click_on 'Invite members' - wait_for_requests + click_on 'Guest' + wait_for_requests - expect(members_table).not_to have_content(other_user.name) + page.within '.dropdown-menu' do + expect(page).to have_button('Guest') + expect(page).to have_button('Reporter') + expect(page).to have_button('Developer') + expect(page).to have_button('Maintainer') + expect(page).not_to have_button('Owner') end + end - it 'invite user to project', :js do - visit_members_page + it 'remove user from project' do + other_user = create(:user) + project.add_developer(other_user) - add_user('test@example.com', 'Reporter') + visit_members_page - click_link 'Invited' + # Open modal + page.within find_member_row(other_user) do + click_button 'Remove member' + end - page.within find_invited_member_row('test@example.com') do - expect(page).to have_button('Reporter') - end + page.within('[role="dialog"]') do + expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests' + click_button('Remove member') end - context 'project bots' do - let(:project_bot) { create(:user, :project_bot, name: 'project_bot') } + wait_for_requests - before do - project.add_maintainer(project_bot) - end + expect(members_table).not_to have_content(other_user.name) + end - it 'does not show form used to change roles and "Expiration date" or the remove user button' do - visit_members_page + it 'invite user to project' do + visit_members_page - page.within find_member_row(project_bot) do - expect(page).not_to have_button('Maintainer') - expect(page).to have_field('Expiration date', disabled: true) - expect(page).not_to have_button('Remove member') - end - end + add_user('test@example.com', 'Reporter') + + click_link 'Invited' + + page.within find_invited_member_row('test@example.com') do + expect(page).to have_button('Reporter') end end - context 'when `vue_project_members_list` feature flag is disabled' do - include Spec::Support::Helpers::Features::ListRowsHelpers + context 'project bots' do + let(:project_bot) { create(:user, :project_bot, name: 'project_bot') } before do - stub_feature_flags(vue_project_members_list: false) + project.add_maintainer(project_bot) end - it 'show members from project and group' do - project.add_developer(user2) - + it 'does not show form used to change roles and "Expiration date" or the remove user button' do visit_members_page - expect(first_row.text).to include(user1.name) - expect(second_row.text).to include(user2.name) + page.within find_member_row(project_bot) do + expect(page).not_to have_button('Maintainer') + expect(page).to have_field('Expiration date', disabled: true) + expect(page).not_to have_button('Remove member') + end end + end - it 'show user once if member of both group and project' do - project.add_developer(user1) - - visit_members_page + describe 'when user has 2FA enabled' do + let_it_be(:admin) { create(:admin) } + let_it_be(:user_with_2fa) { create(:user, :two_factor_via_otp) } - expect(first_row.text).to include(user1.name) - expect(second_row).to be_blank + before do + project.add_guest(user_with_2fa) end - it 'update user access level', :js do - project.add_developer(user2) + it 'shows 2FA badge to user with "Maintainer" access level' do + project.add_maintainer(user1) visit_members_page - page.within(second_row) do - click_button('Developer') - click_link('Reporter') - - expect(page).to have_button('Reporter') - end + expect(find_member_row(user_with_2fa)).to have_content('2FA') end - it 'add user to project', :js do - visit_members_page + it 'shows 2FA badge to admins' do + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) - add_user(user2.id, 'Reporter') + visit_members_page - page.within(second_row) do - expect(page).to have_content(user2.name) - expect(page).to have_button('Reporter') - end + expect(find_member_row(user_with_2fa)).to have_content('2FA') end - it 'remove user from project', :js do - other_user = create(:user) - project.add_developer(other_user) + it 'does not show 2FA badge to users with access level below "Maintainer"' do + group.add_developer(user1) visit_members_page - # Open modal - find(:css, 'li.project_member', text: other_user.name).find(:css, 'button.btn-danger').click - - expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests' - - click_on('Remove member') - - wait_for_requests - - expect(page).not_to have_content(other_user.name) - expect(project.users).not_to include(other_user) + expect(find_member_row(user_with_2fa)).not_to have_content('2FA') end - it 'invite user to project', :js do - visit_members_page - - add_user('test@example.com', 'Reporter') + it 'shows 2FA badge to themselves' do + sign_in(user_with_2fa) - click_link 'Invited' + visit_members_page - page.within(first_row) do - expect(page).to have_content('test@example.com') - expect(page).to have_content('Invited') - expect(page).to have_button('Reporter') - end + expect(find_member_row(user_with_2fa)).to have_content('2FA') end + end - context 'project bots' do - let(:project_bot) { create(:user, :project_bot, name: 'project_bot') } - - before do - project.add_maintainer(project_bot) - end + private - it 'does not show form used to change roles and "Expiration date" or the remove user button' do - project_member = project.project_members.find_by(user_id: project_bot.id) + def add_user(id, role) + click_on 'Invite members' - visit_members_page + page.within '#invite-members-modal' do + fill_in 'Search for members to invite', with: id - expect(page).not_to have_selector("#edit_project_member_#{project_member.id}") - expect(page).to have_no_selector("#project_member_#{project_member.id} .btn-danger") - end - end - end + wait_for_requests + click_button id - private + click_button 'Guest' + wait_for_requests + click_button role - def add_user(id, role) - page.within ".invite-users-form" do - select2(id, from: "#user_ids", multiple: true) - select(role, from: "access_level") + click_button 'Invite' end - click_button "Invite" + page.refresh end def visit_members_page diff --git a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb index 1127c64e0c7..d22097a2f6f 100644 --- a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb +++ b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb @@ -18,107 +18,51 @@ RSpec.describe 'Projects > Members > Maintainer adds member with expiration date sign_in(maintainer) end - context 'when `vue_project_members_list` feature flag is enabled' do - it 'expiration date is displayed in the members list' do - stub_feature_flags(invite_members_group_modal: false) + it 'expiration date is displayed in the members list' do + stub_feature_flags(invite_members_group_modal: false) - visit project_project_members_path(project) + visit project_project_members_path(project) - page.within '.invite-users-form' do - select2(new_member.id, from: '#user_ids', multiple: true) + page.within '.invite-users-form' do + select2(new_member.id, from: '#user_ids', multiple: true) - fill_in 'expires_at', with: 5.days.from_now.to_date - find_field('expires_at').native.send_keys :enter + fill_in 'expires_at', with: 5.days.from_now.to_date + find_field('expires_at').native.send_keys :enter - click_on 'Invite' - end - - page.within find_member_row(new_member) do - expect(page).to have_content(/in \d days/) - end - end - - it 'changes expiration date' do - project.team.add_users([new_member.id], :developer, expires_at: 3.days.from_now.to_date) - visit project_project_members_path(project) - - page.within find_member_row(new_member) do - fill_in 'Expiration date', with: 5.days.from_now.to_date - find_field('Expiration date').native.send_keys :enter - - wait_for_requests - - expect(page).to have_content(/in \d days/) - end + click_on 'Invite' end - it 'clears expiration date' do - project.team.add_users([new_member.id], :developer, expires_at: 5.days.from_now.to_date) - visit project_project_members_path(project) - - page.within find_member_row(new_member) do - expect(page).to have_content(/in \d days/) - - find('[data-testid="clear-button"]').click - - wait_for_requests - - expect(page).to have_content('No expiration set') - end + page.within find_member_row(new_member) do + expect(page).to have_content(/in \d days/) end end - context 'when `vue_project_members_list` feature flag is disabled' do - before do - stub_feature_flags(vue_project_members_list: false) - end - - it 'expiration date is displayed in the members list' do - stub_feature_flags(invite_members_group_modal: false) - - visit project_project_members_path(project) - - page.within '.invite-users-form' do - select2(new_member.id, from: '#user_ids', multiple: true) - - fill_in 'expires_at', with: 3.days.from_now.to_date - find_field('expires_at').native.send_keys :enter + it 'changes expiration date' do + project.team.add_users([new_member.id], :developer, expires_at: 3.days.from_now.to_date) + visit project_project_members_path(project) - click_on 'Invite' - end + page.within find_member_row(new_member) do + fill_in 'Expiration date', with: 5.days.from_now.to_date + find_field('Expiration date').native.send_keys :enter - page.within "#project_member_#{project_member_id}" do - expect(page).to have_content('Expires in 3 days') - end - end - - it 'changes expiration date' do - project.team.add_users([new_member.id], :developer, expires_at: 1.day.from_now.to_date) - visit project_project_members_path(project) - - page.within "#project_member_#{project_member_id}" do - fill_in 'Expiration date', with: 3.days.from_now.to_date - find_field('Expiration date').native.send_keys :enter + wait_for_requests - wait_for_requests - - expect(page).to have_content('Expires in 3 days') - end + expect(page).to have_content(/in \d days/) end + end - it 'clears expiration date' do - project.team.add_users([new_member.id], :developer, expires_at: 3.days.from_now.to_date) - visit project_project_members_path(project) + it 'clears expiration date' do + project.team.add_users([new_member.id], :developer, expires_at: 5.days.from_now.to_date) + visit project_project_members_path(project) - page.within "#project_member_#{project_member_id}" do - expect(page).to have_content('Expires in 3 days') + page.within find_member_row(new_member) do + expect(page).to have_content(/in \d days/) - find('.js-clear-input').click + find('[data-testid="clear-button"]').click - wait_for_requests + wait_for_requests - expect(page).not_to have_content('Expires in') - end + expect(page).to have_content('No expiration set') end end diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb index 3c132747bc4..653564d1566 100644 --- a/spec/features/projects/members/sorting_spec.rb +++ b/spec/features/projects/members/sorting_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Projects > Members > Sorting' do +RSpec.describe 'Projects > Members > Sorting', :js do include Spec::Support::Helpers::Features::MembersHelpers let(:maintainer) { create(:user, name: 'John Doe') } @@ -15,165 +15,85 @@ RSpec.describe 'Projects > Members > Sorting' do sign_in(maintainer) end - context 'when `vue_project_members_list` feature flag is enabled', :js do - it 'sorts by account by default' do - visit_members_list(sort: nil) + it 'sorts by account by default' do + visit_members_list(sort: nil) - expect(first_row).to have_content(maintainer.name) - expect(second_row).to have_content(developer.name) + expect(first_row).to have_content(maintainer.name) + expect(second_row).to have_content(developer.name) - expect_sort_by('Account', :asc) - end - - it 'sorts by max role ascending' do - visit_members_list(sort: :access_level_asc) - - expect(first_row).to have_content(developer.name) - expect(second_row).to have_content(maintainer.name) - - expect_sort_by('Max role', :asc) - end - - it 'sorts by max role descending' do - visit_members_list(sort: :access_level_desc) - - expect(first_row).to have_content(maintainer.name) - expect(second_row).to have_content(developer.name) - - expect_sort_by('Max role', :desc) - end - - it 'sorts by access granted ascending' do - visit_members_list(sort: :last_joined) - - expect(first_row).to have_content(maintainer.name) - expect(second_row).to have_content(developer.name) - - expect_sort_by('Access granted', :asc) - end - - it 'sorts by access granted descending' do - visit_members_list(sort: :oldest_joined) - - expect(first_row).to have_content(developer.name) - expect(second_row).to have_content(maintainer.name) - - expect_sort_by('Access granted', :desc) - end - - it 'sorts by account ascending' do - visit_members_list(sort: :name_asc) - - expect(first_row).to have_content(maintainer.name) - expect(second_row).to have_content(developer.name) - - expect_sort_by('Account', :asc) - end - - it 'sorts by account descending' do - visit_members_list(sort: :name_desc) - - expect(first_row).to have_content(developer.name) - expect(second_row).to have_content(maintainer.name) - - expect_sort_by('Account', :desc) - end + expect_sort_by('Account', :asc) + end - it 'sorts by last sign-in ascending', :clean_gitlab_redis_shared_state do - visit_members_list(sort: :recent_sign_in) + it 'sorts by max role ascending' do + visit_members_list(sort: :access_level_asc) - expect(first_row).to have_content(maintainer.name) - expect(second_row).to have_content(developer.name) + expect(first_row).to have_content(developer.name) + expect(second_row).to have_content(maintainer.name) - expect_sort_by('Last sign-in', :asc) - end + expect_sort_by('Max role', :asc) + end - it 'sorts by last sign-in descending', :clean_gitlab_redis_shared_state do - visit_members_list(sort: :oldest_sign_in) + it 'sorts by max role descending' do + visit_members_list(sort: :access_level_desc) - expect(first_row).to have_content(developer.name) - expect(second_row).to have_content(maintainer.name) + expect(first_row).to have_content(maintainer.name) + expect(second_row).to have_content(developer.name) - expect_sort_by('Last sign-in', :desc) - end + expect_sort_by('Max role', :desc) end - context 'when `vue_project_members_list` feature flag is disabled' do - before do - stub_feature_flags(vue_project_members_list: false) - end - - it 'sorts alphabetically by default' do - visit_members_list(sort: nil) + it 'sorts by access granted ascending' do + visit_members_list(sort: :last_joined) - expect(first_member).to include(maintainer.name) - expect(second_member).to include(developer.name) - expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending') - end + expect(first_row).to have_content(maintainer.name) + expect(second_row).to have_content(developer.name) - it 'sorts by access level ascending' do - visit_members_list(sort: :access_level_asc) + expect_sort_by('Access granted', :asc) + end - expect(first_member).to include(developer.name) - expect(second_member).to include(maintainer.name) - expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Access level, ascending') - end + it 'sorts by access granted descending' do + visit_members_list(sort: :oldest_joined) - it 'sorts by access level descending' do - visit_members_list(sort: :access_level_desc) + expect(first_row).to have_content(developer.name) + expect(second_row).to have_content(maintainer.name) - expect(first_member).to include(maintainer.name) - expect(second_member).to include(developer.name) - expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Access level, descending') - end + expect_sort_by('Access granted', :desc) + end - it 'sorts by last joined' do - visit_members_list(sort: :last_joined) + it 'sorts by account ascending' do + visit_members_list(sort: :name_asc) - expect(first_member).to include(maintainer.name) - expect(second_member).to include(developer.name) - expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Last joined') - end + expect(first_row).to have_content(maintainer.name) + expect(second_row).to have_content(developer.name) - it 'sorts by oldest joined' do - visit_members_list(sort: :oldest_joined) + expect_sort_by('Account', :asc) + end - expect(first_member).to include(developer.name) - expect(second_member).to include(maintainer.name) - expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Oldest joined') - end + it 'sorts by account descending' do + visit_members_list(sort: :name_desc) - it 'sorts by name ascending' do - visit_members_list(sort: :name_asc) + expect(first_row).to have_content(developer.name) + expect(second_row).to have_content(maintainer.name) - expect(first_member).to include(maintainer.name) - expect(second_member).to include(developer.name) - expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending') - end + expect_sort_by('Account', :desc) + end - it 'sorts by name descending' do - visit_members_list(sort: :name_desc) + it 'sorts by last sign-in ascending', :clean_gitlab_redis_shared_state do + visit_members_list(sort: :recent_sign_in) - expect(first_member).to include(developer.name) - expect(second_member).to include(maintainer.name) - expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Name, descending') - end + expect(first_row).to have_content(maintainer.name) + expect(second_row).to have_content(developer.name) - it 'sorts by recent sign in', :clean_gitlab_redis_shared_state do - visit_members_list(sort: :recent_sign_in) + expect_sort_by('Last sign-in', :asc) + end - expect(first_member).to include(maintainer.name) - expect(second_member).to include(developer.name) - expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in') - end + it 'sorts by last sign-in descending', :clean_gitlab_redis_shared_state do + visit_members_list(sort: :oldest_sign_in) - it 'sorts by oldest sign in', :clean_gitlab_redis_shared_state do - visit_members_list(sort: :oldest_sign_in) + expect(first_row).to have_content(developer.name) + expect(second_row).to have_content(maintainer.name) - expect(first_member).to include(developer.name) - expect(second_member).to include(maintainer.name) - expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Oldest sign in') - end + expect_sort_by('Last sign-in', :desc) end private diff --git a/spec/features/projects/members/tabs_spec.rb b/spec/features/projects/members/tabs_spec.rb index eef3395de91..471be26e126 100644 --- a/spec/features/projects/members/tabs_spec.rb +++ b/spec/features/projects/members/tabs_spec.rb @@ -14,6 +14,11 @@ RSpec.describe 'Projects > Members > Tabs' do let_it_be(:invites) { create_list(:project_member, 2, :invited, project: project) } let_it_be(:project_group_links) { create_list(:project_group_link, 2, project: project) } + before do + sign_in(user) + visit project_project_members_path(project) + end + shared_examples 'active "Members" tab' do it 'displays "Members" tab' do expect(page).to have_selector('.nav-link.active', text: 'Members') @@ -21,11 +26,6 @@ RSpec.describe 'Projects > Members > Tabs' do end context 'tabs' do - before do - sign_in(user) - visit project_project_members_path(project) - end - where(:tab, :count) do 'Members' | 3 'Invited' | 2 @@ -44,69 +44,25 @@ RSpec.describe 'Projects > Members > Tabs' do end end - context 'when `vue_project_members_list` feature flag is enabled' do + context 'when searching "Groups"', :js do before do - sign_in(user) - visit project_project_members_path(project) - end - - context 'when searching "Groups"', :js do - before do - click_link 'Groups' - - fill_in_filtered_search 'Search groups', with: 'group' - end - - it 'displays "Groups" tab' do - expect(page).to have_selector('.nav-link.active', text: 'Groups') - end + click_link 'Groups' - context 'and then searching "Members"' do - before do - click_link 'Members 3' - - fill_in_filtered_search 'Filter members', with: 'user' - end - - it_behaves_like 'active "Members" tab' - end + fill_in_filtered_search 'Search groups', with: 'group' end - end - - context 'when `vue_project_members_list` feature flag is disabled' do - before do - stub_feature_flags(vue_project_members_list: false) - sign_in(user) - visit project_project_members_path(project) + it 'displays "Groups" tab' do + expect(page).to have_selector('.nav-link.active', text: 'Groups') end - context 'when searching "Groups"', :js do + context 'and then searching "Members"' do before do - click_link 'Groups' + click_link 'Members 3' - page.within '[data-testid="group-link-search-form"]' do - fill_in 'search_groups', with: 'group' - find('button[type="submit"]').click - end + fill_in_filtered_search 'Filter members', with: 'user' end - it 'displays "Groups" tab' do - expect(page).to have_selector('.nav-link.active', text: 'Groups') - end - - context 'and then searching "Members"' do - before do - click_link 'Members 3' - - page.within '[data-testid="user-search-form"]' do - fill_in 'search', with: 'user' - find('button[type="submit"]').click - end - end - - it_behaves_like 'active "Members" tab' - end + it_behaves_like 'active "Members" tab' end end end diff --git a/spec/features/projects/merge_request_button_spec.rb b/spec/features/projects/merge_request_button_spec.rb index e3d8534ace9..9547ba8a390 100644 --- a/spec/features/projects/merge_request_button_spec.rb +++ b/spec/features/projects/merge_request_button_spec.rb @@ -3,11 +3,13 @@ require 'spec_helper' RSpec.describe 'Merge Request button' do - shared_examples 'Merge request button only shown when allowed' do - let(:user) { create(:user) } - let(:project) { create(:project, :public, :repository) } - let(:forked_project) { create(:project, :public, :repository, forked_from_project: project) } + include ProjectForksHelper + + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :public, :repository) } + let(:forked_project) { fork_project(project, user, repository: true) } + shared_examples 'Merge request button only shown when allowed' do context 'not logged in' do it 'does not show Create merge request button' do visit url @@ -22,10 +24,16 @@ RSpec.describe 'Merge Request button' do project.add_developer(user) end - it 'shows Create merge request button' do - href = project_new_merge_request_path(project, - merge_request: { source_branch: 'feature', - target_branch: 'master' }) + it 'shows Create merge request button', :js do + href = project_new_merge_request_path( + project, + merge_request: { + source_project_id: project.id, + source_branch: 'feature', + target_project_id: project.id, + target_branch: 'master' + } + ) visit url @@ -75,12 +83,16 @@ RSpec.describe 'Merge Request button' do end context 'on own fork of project' do - let(:user) { forked_project.owner } - - it 'shows Create merge request button' do - href = project_new_merge_request_path(forked_project, - merge_request: { source_branch: 'feature', - target_branch: 'master' }) + it 'shows Create merge request button', :js do + href = project_new_merge_request_path( + forked_project, + merge_request: { + source_project_id: forked_project.id, + source_branch: 'feature', + target_project_id: forked_project.id, + target_branch: 'master' + } + ) visit fork_url @@ -101,11 +113,33 @@ RSpec.describe 'Merge Request button' do end context 'on compare page' do + let(:label) { 'Create merge request' } + it_behaves_like 'Merge request button only shown when allowed' do - let(:label) { 'Create merge request' } let(:url) { project_compare_path(project, from: 'master', to: 'feature') } let(:fork_url) { project_compare_path(forked_project, from: 'master', to: 'feature') } end + + it 'shows the correct merge request button when viewing across forks', :js do + sign_in(user) + project.add_developer(user) + + href = project_new_merge_request_path( + project, + merge_request: { + source_project_id: forked_project.id, + source_branch: 'feature', + target_project_id: project.id, + target_branch: 'master' + } + ) + + visit project_compare_path(forked_project, from: 'master', to: 'feature', from_project_id: project.id) + + within("#content-body") do + expect(page).to have_link(label, href: href) + end + end end context 'on commits page' do diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index 4aabf040655..ec34640bd00 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -86,7 +86,7 @@ RSpec.describe 'New project', :js do visit new_project_path find('[data-qa-selector="blank_project_link"]').click - choose(s_(key)) + choose(key) click_button('Create project') page.within('#blank-project-pane') do expect(find_field("project_visibility_level_#{level}")).to be_checked @@ -95,33 +95,55 @@ RSpec.describe 'New project', :js do end context 'when group visibility is private but default is internal' do + let_it_be(:group) { create(:group, visibility_level: Gitlab::VisibilityLevel::PRIVATE) } + before do stub_application_setting(default_project_visibility: Gitlab::VisibilityLevel::INTERNAL) end - it 'has private selected' do - group = create(:group, visibility_level: Gitlab::VisibilityLevel::PRIVATE) - visit new_project_path(namespace_id: group.id) - find('[data-qa-selector="blank_project_link"]').click + context 'when admin mode is enabled', :enable_admin_mode do + it 'has private selected' do + visit new_project_path(namespace_id: group.id) + find('[data-qa-selector="blank_project_link"]').click - page.within('#blank-project-pane') do - expect(find_field("project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).to be_checked + page.within('#blank-project-pane') do + expect(find_field("project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).to be_checked + end + end + end + + context 'when admin mode is disabled' do + it 'is not allowed' do + visit new_project_path(namespace_id: group.id) + + expect(page).to have_content('Not Found') end end end context 'when group visibility is public but user requests private' do + let_it_be(:group) { create(:group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } + before do stub_application_setting(default_project_visibility: Gitlab::VisibilityLevel::INTERNAL) end - it 'has private selected' do - group = create(:group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) - visit new_project_path(namespace_id: group.id, project: { visibility_level: Gitlab::VisibilityLevel::PRIVATE }) - find('[data-qa-selector="blank_project_link"]').click + context 'when admin mode is enabled', :enable_admin_mode do + it 'has private selected' do + visit new_project_path(namespace_id: group.id, project: { visibility_level: Gitlab::VisibilityLevel::PRIVATE }) + find('[data-qa-selector="blank_project_link"]').click - page.within('#blank-project-pane') do - expect(find_field("project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).to be_checked + page.within('#blank-project-pane') do + expect(find_field("project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).to be_checked + end + end + end + + context 'when admin mode is disabled' do + it 'is not allowed' do + visit new_project_path(namespace_id: group.id, project: { visibility_level: Gitlab::VisibilityLevel::PRIVATE }) + + expect(page).to have_content('Not Found') end end end diff --git a/spec/features/projects/pages/user_edits_settings_spec.rb b/spec/features/projects/pages/user_edits_settings_spec.rb index 3649fae17ce..6156b5243de 100644 --- a/spec/features/projects/pages/user_edits_settings_spec.rb +++ b/spec/features/projects/pages/user_edits_settings_spec.rb @@ -140,7 +140,7 @@ RSpec.describe 'Pages edits pages settings', :js do before do allow(Projects::UpdateService).to receive(:new).and_return(service) - allow(service).to receive(:execute).and_return(status: :error, message: 'Some error has occured') + allow(service).to receive(:execute).and_return(status: :error, message: 'Some error has occurred') end it 'tries to change the setting' do @@ -150,7 +150,7 @@ RSpec.describe 'Pages edits pages settings', :js do click_button 'Save' - expect(page).to have_text('Some error has occured') + expect(page).to have_text('Some error has occurred') end end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index 6421d3db2cd..9037aa5c9a8 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -14,6 +14,7 @@ RSpec.describe 'Pipelines', :js do sign_in(user) stub_feature_flags(graphql_pipeline_details: false) stub_feature_flags(graphql_pipeline_details_users: false) + stub_feature_flags(new_pipelines_table: false) project.add_developer(user) project.update!(auto_devops_attributes: { enabled: false }) @@ -519,75 +520,58 @@ RSpec.describe 'Pipelines', :js do end end - shared_examples 'mini pipeline renders' do |ci_mini_pipeline_gl_dropdown_enabled| - context 'mini pipeline graph' do - let!(:build) do - create(:ci_build, :pending, pipeline: pipeline, - stage: 'build', - name: 'build') - end + context 'mini pipeline graph' do + let!(:build) do + create(:ci_build, :pending, pipeline: pipeline, + stage: 'build', + name: 'build') + end - before do - stub_feature_flags(ci_mini_pipeline_gl_dropdown: ci_mini_pipeline_gl_dropdown_enabled) - visit_project_pipelines - end + dropdown_selector = '[data-testid="mini-pipeline-graph-dropdown"]' - let_it_be(:dropdown_toggle_selector) do - if ci_mini_pipeline_gl_dropdown_enabled - '[data-testid="mini-pipeline-graph-dropdown"] .dropdown-toggle' - else - '[data-testid="mini-pipeline-graph-dropdown-toggle"]' - end - end + before do + visit_project_pipelines + end - it 'renders a mini pipeline graph' do - expect(page).to have_selector('[data-testid="widget-mini-pipeline-graph"]') - expect(page).to have_selector(dropdown_toggle_selector) - end + it 'renders a mini pipeline graph' do + expect(page).to have_selector('[data-testid="widget-mini-pipeline-graph"]') + expect(page).to have_selector(dropdown_selector) + end - context 'when clicking a stage badge' do - it 'opens a dropdown' do - find(dropdown_toggle_selector).click + context 'when clicking a stage badge' do + it 'opens a dropdown' do + find(dropdown_selector).click - expect(page).to have_link build.name - end + expect(page).to have_link build.name + end - it 'is possible to cancel pending build' do - find(dropdown_toggle_selector).click - find('.js-ci-action').click - wait_for_requests + it 'is possible to cancel pending build' do + find(dropdown_selector).click + find('.js-ci-action').click + wait_for_requests - expect(build.reload).to be_canceled - end + expect(build.reload).to be_canceled end + end - context 'for a failed pipeline' do - let!(:build) do - create(:ci_build, :failed, pipeline: pipeline, - stage: 'build', - name: 'build') - end + context 'for a failed pipeline' do + let!(:build) do + create(:ci_build, :failed, pipeline: pipeline, + stage: 'build', + name: 'build') + end - it 'displays the failure reason' do - find(dropdown_toggle_selector).click + it 'displays the failure reason' do + find(dropdown_selector).click - within('.js-builds-dropdown-list') do - build_element = page.find('.mini-pipeline-graph-dropdown-item') - expect(build_element['title']).to eq('build - failed - (unknown failure)') - end + within('.js-builds-dropdown-list') do + build_element = page.find('.mini-pipeline-graph-dropdown-item') + expect(build_element['title']).to eq('build - failed - (unknown failure)') end end end end - context 'with ci_mini_pipeline_gl_dropdown disabled' do - it_behaves_like "mini pipeline renders", false - end - - context 'with ci_mini_pipeline_gl_dropdown enabled' do - it_behaves_like "mini pipeline renders", true - end - context 'with pagination' do before do allow(Ci::Pipeline).to receive(:default_per_page).and_return(1) diff --git a/spec/features/projects/releases/user_creates_release_spec.rb b/spec/features/projects/releases/user_creates_release_spec.rb index 2acdf983cf2..9e428a0623d 100644 --- a/spec/features/projects/releases/user_creates_release_spec.rb +++ b/spec/features/projects/releases/user_creates_release_spec.rb @@ -33,11 +33,11 @@ RSpec.describe 'User creates release', :js do end it 'defaults the "Create from" dropdown to the project\'s default branch' do - expect(page.find('.ref-selector button')).to have_content(project.default_branch) + expect(page.find('[data-testid="create-from-field"] .ref-selector button')).to have_content(project.default_branch) end - context 'when the "Save release" button is clicked', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/297507' do - let(:tag_name) { 'v1.0' } + context 'when the "Save release" button is clicked' do + let(:tag_name) { 'v2.0.31' } let(:release_title) { 'A most magnificent release' } let(:release_notes) { 'Best. Release. **Ever.** :rocket:' } let(:link_1) { { url: 'https://gitlab.example.com/runbook', title: 'An example runbook', type: 'runbook' } } @@ -47,7 +47,7 @@ RSpec.describe 'User creates release', :js do fill_out_form_and_submit end - it 'creates a new release when "Create release" is clicked', :aggregate_failures do + it 'creates a new release when "Create release" is clicked and redirects to the release\'s dedicated page', :aggregate_failures do release = project.releases.last expect(release.tag).to eq(tag_name) @@ -65,10 +65,6 @@ RSpec.describe 'User creates release', :js do link = release.links.find { |l| l.link_type == link_2[:type] } expect(link.url).to eq(link_2[:url]) expect(link.name).to eq(link_2[:title]) - end - - it 'redirects to the dedicated page for the newly created release' do - release = project.releases.last expect(page).to have_current_path(project_release_path(project, release)) end @@ -116,30 +112,27 @@ RSpec.describe 'User creates release', :js do end def fill_out_form_and_submit - fill_tag_name(tag_name) + select_new_tag_name(tag_name) select_create_from(branch.name) fill_release_title(release_title) - select_milestone(milestone_1.title, and_tab: false) + select_milestone(milestone_1.title) select_milestone(milestone_2.title) - # Focus the "Release notes" field by clicking instead of tabbing - # because tabbing to the field requires too many tabs - # (see https://gitlab.com/gitlab-org/gitlab/-/issues/238619) - find_field('Release notes').click fill_release_notes(release_notes) - # Tab past the "assets" documentation link - focused_element.send_keys(:tab) - fill_asset_link(link_1) add_another_asset_link fill_asset_link(link_2) - # Submit using the Control+Enter shortcut - focused_element.send_keys([:control, :enter]) + # Click on the body in order to trigger a `blur` event on the current field. + # This triggers the form's validation to run so that the + # "Create release" button is enabled and clickable. + page.find('body').click + + click_button('Create release') wait_for_all_requests end diff --git a/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb b/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb index 32519b14d4e..88812fc188b 100644 --- a/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb +++ b/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb @@ -113,7 +113,7 @@ RSpec.describe 'Set up Mattermost slash commands', :js do click_link 'Add to Mattermost' - expect(page).to have_selector('.alert') + expect(page).to have_selector('.gl-alert') expect(page).to have_content('test mattermost error message') end diff --git a/spec/features/projects/settings/operations_settings_spec.rb b/spec/features/projects/settings/operations_settings_spec.rb index 1d9f256a819..fe0ee52e4fa 100644 --- a/spec/features/projects/settings/operations_settings_spec.rb +++ b/spec/features/projects/settings/operations_settings_spec.rb @@ -24,7 +24,7 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do describe 'Settings > Operations' do describe 'Incidents' do let(:create_issue) { 'Create an incident. Incidents are created for each alert triggered.' } - let(:send_email) { 'Send a separate email notification to Developers.' } + let(:send_email) { 'Send a single email notification to Owners and Maintainers for new alerts.' } before do create(:project_incident_management_setting, send_email: true, project: project) @@ -162,13 +162,13 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do end expect(page).to have_content('Grafana URL') - expect(page).to have_content('API Token') - expect(page).to have_button('Save Changes') + expect(page).to have_content('API token') + expect(page).to have_button('Save changes') fill_in('grafana-url', with: 'http://gitlab-test.grafana.net') fill_in('grafana-token', with: 'token') - click_button('Save Changes') + click_button('Save changes') wait_for_requests diff --git a/spec/features/projects/settings/service_desk_setting_spec.rb b/spec/features/projects/settings/service_desk_setting_spec.rb index d31913d2dcf..50451075db5 100644 --- a/spec/features/projects/settings/service_desk_setting_spec.rb +++ b/spec/features/projects/settings/service_desk_setting_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Service Desk Setting', :js do +RSpec.describe 'Service Desk Setting', :js, :clean_gitlab_redis_cache do let(:project) { create(:project_empty_repo, :private, service_desk_enabled: false) } let(:presenter) { project.present(current_user: user) } let(:user) { create(:user) } @@ -66,5 +66,48 @@ RSpec.describe 'Service Desk Setting', :js do expect(find('[data-testid="incoming-email"]').value).to eq('address-suffix@example.com') end + + context 'issue description templates' do + let_it_be(:issuable_project_template_files) do + { + '.gitlab/issue_templates/project-issue-bar.md' => 'Project Issue Template Bar', + '.gitlab/issue_templates/project-issue-foo.md' => 'Project Issue Template Foo' + } + end + + let_it_be(:issuable_group_template_files) do + { + '.gitlab/issue_templates/group-issue-bar.md' => 'Group Issue Template Bar', + '.gitlab/issue_templates/group-issue-foo.md' => 'Group Issue Template Foo' + } + end + + let_it_be_with_reload(:group) { create(:group)} + let_it_be_with_reload(:project) { create(:project, :custom_repo, group: group, files: issuable_project_template_files) } + let_it_be(:group_template_repo) { create(:project, :custom_repo, group: group, files: issuable_group_template_files) } + + before do + stub_licensed_features(custom_file_templates_for_namespace: false, custom_file_templates: false) + group.update_columns(file_template_project_id: group_template_repo.id) + end + + context 'when inherited_issuable_templates enabled' do + before do + stub_feature_flags(inherited_issuable_templates: true) + visit edit_project_path(project) + end + + it_behaves_like 'issue description templates from current project only' + end + + context 'when inherited_issuable_templates disabled' do + before do + stub_feature_flags(inherited_issuable_templates: false) + visit edit_project_path(project) + end + + it_behaves_like 'issue description templates from current project only' + end + end end end diff --git a/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb b/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb index e8e32d93f7b..397c334a2b8 100644 --- a/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb +++ b/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb @@ -133,7 +133,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do it 'when unchecked sets :remove_source_branch_after_merge to false' do uncheck('project_remove_source_branch_after_merge') within('.merge-request-settings-form') do - find('.qa-save-merge-request-changes') + find('.rspec-save-merge-request-changes') click_on('Save changes') end @@ -157,7 +157,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do choose('project_project_setting_attributes_squash_option_default_on') within('.merge-request-settings-form') do - find('.qa-save-merge-request-changes') + find('.rspec-save-merge-request-changes') click_on('Save changes') end @@ -172,7 +172,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do choose('project_project_setting_attributes_squash_option_always') within('.merge-request-settings-form') do - find('.qa-save-merge-request-changes') + find('.rspec-save-merge-request-changes') click_on('Save changes') end @@ -187,7 +187,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do choose('project_project_setting_attributes_squash_option_never') within('.merge-request-settings-form') do - find('.qa-save-merge-request-changes') + find('.rspec-save-merge-request-changes') click_on('Save changes') end diff --git a/spec/features/projects/settings/user_manages_project_members_spec.rb b/spec/features/projects/settings/user_manages_project_members_spec.rb index 0d22da34b91..b237e7e8ce7 100644 --- a/spec/features/projects/settings/user_manages_project_members_spec.rb +++ b/spec/features/projects/settings/user_manages_project_members_spec.rb @@ -19,123 +19,54 @@ RSpec.describe 'Projects > Settings > User manages project members' do sign_in(user) end - context 'when `vue_project_members_list` feature flag is enabled' do - it 'cancels a team member', :js do - visit(project_project_members_path(project)) + it 'cancels a team member', :js do + visit(project_project_members_path(project)) - page.within find_member_row(user_dmitriy) do - click_button 'Remove member' - end - - page.within('[role="dialog"]') do - expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests' - click_button('Remove member') - end - - visit(project_project_members_path(project)) - - expect(members_table).not_to have_content(user_dmitriy.name) - expect(members_table).not_to have_content(user_dmitriy.username) + page.within find_member_row(user_dmitriy) do + click_button 'Remove member' end - it 'imports a team from another project', :js do - stub_feature_flags(invite_members_group_modal: false) - - project2.add_maintainer(user) - project2.add_reporter(user_mike) - - visit(project_project_members_path(project)) - - page.within('.invite-users-form') do - click_link('Import') - end - - select2(project2.id, from: '#source_project_id') - click_button('Import project members') - - expect(find_member_row(user_mike)).to have_content('Reporter') + page.within('[role="dialog"]') do + expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests' + click_button('Remove member') end - it 'shows all members of project shared group', :js do - group.add_owner(user) - group.add_developer(user_dmitriy) - - share_link = project.project_group_links.new(group_access: Gitlab::Access::MAINTAINER) - share_link.group_id = group.id - share_link.save! - - visit(project_project_members_path(project)) + visit(project_project_members_path(project)) - click_link 'Groups' - - expect(find_group_row(group)).to have_content('Maintainer') - end + expect(members_table).not_to have_content(user_dmitriy.name) + expect(members_table).not_to have_content(user_dmitriy.username) end - context 'when `vue_project_members_list` feature flag is disabled' do - before do - stub_feature_flags(vue_project_members_list: false) - end + it 'imports a team from another project', :js do + stub_feature_flags(invite_members_group_modal: false) - it 'cancels a team member', :js do - visit(project_project_members_path(project)) + project2.add_maintainer(user) + project2.add_reporter(user_mike) - project_member = project.project_members.find_by(user_id: user_dmitriy.id) + visit(project_project_members_path(project)) - page.within("#project_member_#{project_member.id}") do - # Open modal - click_on('Remove user from project') - end - - expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests' - - click_on('Remove member') - - visit(project_project_members_path(project)) - - expect(page).not_to have_content(user_dmitriy.name) - expect(page).not_to have_content(user_dmitriy.username) + page.within('.invite-users-form') do + click_link('Import') end - it 'imports a team from another project' do - stub_feature_flags(invite_members_group_modal: false) - - project2.add_maintainer(user) - project2.add_reporter(user_mike) - - visit(project_project_members_path(project)) + select2(project2.id, from: '#source_project_id') + click_button('Import project members') - page.within('.invite-users-form') do - click_link('Import') - end - - select(project2.full_name, from: 'source_project_id') - click_button('Import') - - project_member = project.project_members.find_by(user_id: user_mike.id) - - page.within("#project_member_#{project_member.id}") do - expect(page).to have_content('Mike') - expect(page).to have_content('Reporter') - end - end + expect(find_member_row(user_mike)).to have_content('Reporter') + end - it 'shows all members of project shared group', :js do - group.add_owner(user) - group.add_developer(user_dmitriy) + it 'shows all members of project shared group', :js do + group.add_owner(user) + group.add_developer(user_dmitriy) - share_link = project.project_group_links.new(group_access: Gitlab::Access::MAINTAINER) - share_link.group_id = group.id - share_link.save! + share_link = project.project_group_links.new(group_access: Gitlab::Access::MAINTAINER) + share_link.group_id = group.id + share_link.save! - visit(project_project_members_path(project)) + visit(project_project_members_path(project)) - click_link 'Groups' + click_link 'Groups' - page.within('[data-testid="project-member-groups"]') do - expect(page).to have_content('OpenSource') - expect(first('.group_member')).to have_content('Maintainer') - end - end + expect(find_group_row(group)).to have_content('Maintainer') end end diff --git a/spec/features/projects/settings/user_searches_in_settings_spec.rb b/spec/features/projects/settings/user_searches_in_settings_spec.rb new file mode 100644 index 00000000000..4c5b39d5282 --- /dev/null +++ b/spec/features/projects/settings/user_searches_in_settings_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'User searches project settings', :js do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :repository, namespace: user.namespace) } + + before do + sign_in(user) + end + + context 'in general settings page' do + let(:visit_path) { edit_project_path(project) } + + it_behaves_like 'can search settings with feature flag check', 'Naming', 'Visibility' + end + + context 'in Repository page' do + before do + visit project_settings_repository_path(project) + end + + it_behaves_like 'can search settings', 'Deploy keys', 'Mirroring repositories' + end + + context 'in CI/CD page' do + before do + visit project_settings_ci_cd_path(project) + end + + it_behaves_like 'can search settings', 'General pipelines', 'Auto DevOps' + end + + context 'in Operations page' do + before do + visit project_settings_operations_path(project) + end + + it_behaves_like 'can search settings', 'Alerts', 'Error tracking' + end +end diff --git a/spec/features/projects/show/user_manages_notifications_spec.rb b/spec/features/projects/show/user_manages_notifications_spec.rb index 5f7d9b0963b..80dae4ec386 100644 --- a/spec/features/projects/show/user_manages_notifications_spec.rb +++ b/spec/features/projects/show/user_manages_notifications_spec.rb @@ -6,38 +6,36 @@ RSpec.describe 'Projects > Show > User manages notifications', :js do let(:project) { create(:project, :public, :repository) } before do - stub_feature_flags(vue_notification_dropdown: false) sign_in(project.owner) end def click_notifications_button - first('.notifications-btn').click + first('[data-testid="notification-dropdown"]').click end it 'changes the notification setting' do visit project_path(project) click_notifications_button - click_link 'On mention' + click_button 'On mention' - page.within('.notification-dropdown') do - expect(page).not_to have_css('.gl-spinner') - end + wait_for_requests click_notifications_button - expect(find('.update-notification.is-active')).to have_content('On mention') - expect(page).to have_css('.notifications-icon[data-testid="notifications-icon"]') + + page.within first('[data-testid="notification-dropdown"]') do + expect(page.find('.gl-new-dropdown-item.is-active')).to have_content('On mention') + expect(page).to have_css('[data-testid="notifications-icon"]') + end end it 'changes the notification setting to disabled' do visit project_path(project) click_notifications_button - click_link 'Disabled' + click_button 'Disabled' - page.within('.notification-dropdown') do - expect(page).not_to have_css('.gl-spinner') + page.within first('[data-testid="notification-dropdown"]') do + expect(page).to have_css('[data-testid="notifications-off-icon"]') end - - expect(page).to have_css('.notifications-icon[data-testid="notifications-off-icon"]') end context 'custom notification settings' do @@ -65,11 +63,13 @@ RSpec.describe 'Projects > Show > User manages notifications', :js do it 'shows notification settings checkbox' do visit project_path(project) click_notifications_button - page.find('a[data-notification-level="custom"]').click + click_button 'Custom' + + wait_for_requests - page.within('.custom-notifications-form') do + page.within('#custom-notifications-modal') do email_events.each do |event_name| - expect(page).to have_selector("input[name='notification_setting[#{event_name}]']") + expect(page).to have_selector("[data-testid='notification-setting-#{event_name}']") end end end @@ -80,7 +80,7 @@ RSpec.describe 'Projects > Show > User manages notifications', :js do it 'is disabled' do visit project_path(project) - expect(page).to have_selector('.notifications-btn.disabled', visible: true) + expect(page).to have_selector('[data-testid="notification-dropdown"] .disabled', visible: true) end end end diff --git a/spec/features/projects/show/user_uploads_files_spec.rb b/spec/features/projects/show/user_uploads_files_spec.rb index 053598a528e..2030c4d998a 100644 --- a/spec/features/projects/show/user_uploads_files_spec.rb +++ b/spec/features/projects/show/user_uploads_files_spec.rb @@ -33,4 +33,24 @@ RSpec.describe 'Projects > Show > User uploads files' do include_examples 'it uploads and commit a new file to a forked project' end + + context 'when in the empty_repo_upload experiment' do + before do + stub_experiments(empty_repo_upload: :candidate) + + visit(project_path(project)) + end + + context 'with an empty repo' do + let(:project) { create(:project, :empty_repo, creator: user) } + + include_examples 'uploads and commits a new text file via "upload file" button' + end + + context 'with a nonempty repo' do + let(:project) { create(:project, :repository, creator: user) } + + include_examples 'uploads and commits a new text file via "upload file" button' + end + end end diff --git a/spec/features/projects/user_sees_sidebar_spec.rb b/spec/features/projects/user_sees_sidebar_spec.rb index 616c5065c07..e5ba6b503cc 100644 --- a/spec/features/projects/user_sees_sidebar_spec.rb +++ b/spec/features/projects/user_sees_sidebar_spec.rb @@ -201,7 +201,7 @@ RSpec.describe 'Projects > User sees sidebar' do expect(page).to have_content 'Operations' expect(page).not_to have_content 'Repository' - expect(page).not_to have_content 'CI / CD' + expect(page).not_to have_content 'CI/CD' expect(page).not_to have_content 'Merge Requests' end end @@ -213,7 +213,7 @@ RSpec.describe 'Projects > User sees sidebar' do visit project_path(project) within('.nav-sidebar') do - expect(page).to have_content 'CI / CD' + expect(page).to have_content 'CI/CD' end end diff --git a/spec/features/projects/user_uses_shortcuts_spec.rb b/spec/features/projects/user_uses_shortcuts_spec.rb index 13ae035e8ef..f97c8d820e3 100644 --- a/spec/features/projects/user_uses_shortcuts_spec.rb +++ b/spec/features/projects/user_uses_shortcuts_spec.rb @@ -155,12 +155,12 @@ RSpec.describe 'User uses shortcuts', :js do end end - context 'when navigating to the CI / CD pages' do + context 'when navigating to the CI/CD pages' do it 'redirects to the Jobs page' do find('body').native.send_key('g') find('body').native.send_key('j') - expect(page).to have_active_navigation('CI / CD') + expect(page).to have_active_navigation('CI/CD') expect(page).to have_active_sub_navigation('Jobs') end end diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index 618a256d4fb..4730679feb8 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -49,7 +49,7 @@ RSpec.describe 'Project' do it 'shows the command in a popover', :js do click_link 'Show command' - expect(page).to have_css('.popover .push-to-create-popover #push_to_create_tip') + expect(page).to have_css('.popover #push-to-create-tip') expect(page).to have_content 'Private projects can be created in your personal namespace with:' end end diff --git a/spec/features/security/group/internal_access_spec.rb b/spec/features/security/group/internal_access_spec.rb index c146ac1e8ee..755f170a93e 100644 --- a/spec/features/security/group/internal_access_spec.rb +++ b/spec/features/security/group/internal_access_spec.rb @@ -24,7 +24,12 @@ RSpec.describe 'Internal Group access' do describe 'GET /groups/:path' do subject { group_path(group) } - it { is_expected.to be_allowed_for(:admin) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed_for(:admin) } + end + context 'when admin mode is disabled' do + it { is_expected.to be_allowed_for(:admin) } + end it { is_expected.to be_allowed_for(:owner).of(group) } it { is_expected.to be_allowed_for(:maintainer).of(group) } it { is_expected.to be_allowed_for(:developer).of(group) } @@ -39,7 +44,12 @@ RSpec.describe 'Internal Group access' do describe 'GET /groups/:path/-/issues' do subject { issues_group_path(group) } - it { is_expected.to be_allowed_for(:admin) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed_for(:admin) } + end + context 'when admin mode is disabled' do + it { is_expected.to be_allowed_for(:admin) } + end it { is_expected.to be_allowed_for(:owner).of(group) } it { is_expected.to be_allowed_for(:maintainer).of(group) } it { is_expected.to be_allowed_for(:developer).of(group) } @@ -56,7 +66,12 @@ RSpec.describe 'Internal Group access' do subject { merge_requests_group_path(group) } - it { is_expected.to be_allowed_for(:admin) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed_for(:admin) } + end + context 'when admin mode is disabled' do + it { is_expected.to be_allowed_for(:admin) } + end it { is_expected.to be_allowed_for(:owner).of(group) } it { is_expected.to be_allowed_for(:maintainer).of(group) } it { is_expected.to be_allowed_for(:developer).of(group) } @@ -71,7 +86,12 @@ RSpec.describe 'Internal Group access' do describe 'GET /groups/:path/-/group_members' do subject { group_group_members_path(group) } - it { is_expected.to be_allowed_for(:admin) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed_for(:admin) } + end + context 'when admin mode is disabled' do + it { is_expected.to be_allowed_for(:admin) } + end it { is_expected.to be_allowed_for(:owner).of(group) } it { is_expected.to be_allowed_for(:maintainer).of(group) } it { is_expected.to be_allowed_for(:developer).of(group) } @@ -86,7 +106,12 @@ RSpec.describe 'Internal Group access' do describe 'GET /groups/:path/-/edit' do subject { edit_group_path(group) } - it { is_expected.to be_allowed_for(:admin) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed_for(:admin) } + end + context 'when admin mode is disabled' do + it { is_expected.to be_denied_for(:admin) } + end it { is_expected.to be_allowed_for(:owner).of(group) } it { is_expected.to be_denied_for(:maintainer).of(group) } it { is_expected.to be_denied_for(:developer).of(group) } diff --git a/spec/features/security/group/private_access_spec.rb b/spec/features/security/group/private_access_spec.rb index de05b4d3d16..fc1fb3e3848 100644 --- a/spec/features/security/group/private_access_spec.rb +++ b/spec/features/security/group/private_access_spec.rb @@ -24,7 +24,12 @@ RSpec.describe 'Private Group access' do describe 'GET /groups/:path' do subject { group_path(group) } - it { is_expected.to be_allowed_for(:admin) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed_for(:admin) } + end + context 'when admin mode is disabled' do + it { is_expected.to be_denied_for(:admin) } + end it { is_expected.to be_allowed_for(:owner).of(group) } it { is_expected.to be_allowed_for(:maintainer).of(group) } it { is_expected.to be_allowed_for(:developer).of(group) } @@ -39,7 +44,12 @@ RSpec.describe 'Private Group access' do describe 'GET /groups/:path/-/issues' do subject { issues_group_path(group) } - it { is_expected.to be_allowed_for(:admin) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed_for(:admin) } + end + context 'when admin mode is disabled' do + it { is_expected.to be_denied_for(:admin) } + end it { is_expected.to be_allowed_for(:owner).of(group) } it { is_expected.to be_allowed_for(:maintainer).of(group) } it { is_expected.to be_allowed_for(:developer).of(group) } @@ -56,7 +66,12 @@ RSpec.describe 'Private Group access' do subject { merge_requests_group_path(group) } - it { is_expected.to be_allowed_for(:admin) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed_for(:admin) } + end + context 'when admin mode is disabled' do + it { is_expected.to be_denied_for(:admin) } + end it { is_expected.to be_allowed_for(:owner).of(group) } it { is_expected.to be_allowed_for(:maintainer).of(group) } it { is_expected.to be_allowed_for(:developer).of(group) } @@ -71,7 +86,12 @@ RSpec.describe 'Private Group access' do describe 'GET /groups/:path/-/group_members' do subject { group_group_members_path(group) } - it { is_expected.to be_allowed_for(:admin) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed_for(:admin) } + end + context 'when admin mode is disabled' do + it { is_expected.to be_denied_for(:admin) } + end it { is_expected.to be_allowed_for(:owner).of(group) } it { is_expected.to be_allowed_for(:maintainer).of(group) } it { is_expected.to be_allowed_for(:developer).of(group) } @@ -86,7 +106,12 @@ RSpec.describe 'Private Group access' do describe 'GET /groups/:path/-/edit' do subject { edit_group_path(group) } - it { is_expected.to be_allowed_for(:admin) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed_for(:admin) } + end + context 'when admin mode is disabled' do + it { is_expected.to be_denied_for(:admin) } + end it { is_expected.to be_allowed_for(:owner).of(group) } it { is_expected.to be_denied_for(:maintainer).of(group) } it { is_expected.to be_denied_for(:developer).of(group) } @@ -107,7 +132,12 @@ RSpec.describe 'Private Group access' do subject { group_path(group) } - it { is_expected.to be_allowed_for(:admin) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed_for(:admin) } + end + context 'when admin mode is disabled' do + it { is_expected.to be_denied_for(:admin) } + end it { is_expected.to be_allowed_for(:owner).of(group) } it { is_expected.to be_allowed_for(:maintainer).of(group) } it { is_expected.to be_allowed_for(:developer).of(group) } diff --git a/spec/features/security/group/public_access_spec.rb b/spec/features/security/group/public_access_spec.rb index ee72b84616a..90de2b58044 100644 --- a/spec/features/security/group/public_access_spec.rb +++ b/spec/features/security/group/public_access_spec.rb @@ -24,7 +24,12 @@ RSpec.describe 'Public Group access' do describe 'GET /groups/:path' do subject { group_path(group) } - it { is_expected.to be_allowed_for(:admin) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed_for(:admin) } + end + context 'when admin mode is disabled' do + it { is_expected.to be_allowed_for(:admin) } + end it { is_expected.to be_allowed_for(:owner).of(group) } it { is_expected.to be_allowed_for(:maintainer).of(group) } it { is_expected.to be_allowed_for(:developer).of(group) } @@ -39,7 +44,12 @@ RSpec.describe 'Public Group access' do describe 'GET /groups/:path/-/issues' do subject { issues_group_path(group) } - it { is_expected.to be_allowed_for(:admin) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed_for(:admin) } + end + context 'when admin mode is disabled' do + it { is_expected.to be_allowed_for(:admin) } + end it { is_expected.to be_allowed_for(:owner).of(group) } it { is_expected.to be_allowed_for(:maintainer).of(group) } it { is_expected.to be_allowed_for(:developer).of(group) } @@ -56,7 +66,12 @@ RSpec.describe 'Public Group access' do subject { merge_requests_group_path(group) } - it { is_expected.to be_allowed_for(:admin) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed_for(:admin) } + end + context 'when admin mode is disabled' do + it { is_expected.to be_allowed_for(:admin) } + end it { is_expected.to be_allowed_for(:owner).of(group) } it { is_expected.to be_allowed_for(:maintainer).of(group) } it { is_expected.to be_allowed_for(:developer).of(group) } @@ -71,7 +86,12 @@ RSpec.describe 'Public Group access' do describe 'GET /groups/:path/-/group_members' do subject { group_group_members_path(group) } - it { is_expected.to be_allowed_for(:admin) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed_for(:admin) } + end + context 'when admin mode is disabled' do + it { is_expected.to be_allowed_for(:admin) } + end it { is_expected.to be_allowed_for(:owner).of(group) } it { is_expected.to be_allowed_for(:maintainer).of(group) } it { is_expected.to be_allowed_for(:developer).of(group) } @@ -86,7 +106,12 @@ RSpec.describe 'Public Group access' do describe 'GET /groups/:path/-/edit' do subject { edit_group_path(group) } - it { is_expected.to be_allowed_for(:admin) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed_for(:admin) } + end + context 'when admin mode is disabled' do + it { is_expected.to be_denied_for(:admin) } + end it { is_expected.to be_allowed_for(:owner).of(group) } it { is_expected.to be_denied_for(:maintainer).of(group) } it { is_expected.to be_denied_for(:developer).of(group) } diff --git a/spec/features/sentry_js_spec.rb b/spec/features/sentry_js_spec.rb index aa0ad17340a..1d277ba7b3c 100644 --- a/spec/features/sentry_js_spec.rb +++ b/spec/features/sentry_js_spec.rb @@ -12,7 +12,7 @@ RSpec.describe 'Sentry' do expect(has_requested_sentry).to eq(false) end - xit 'loads sentry if sentry is enabled' do + it 'loads sentry if sentry is enabled' do stub_sentry_settings visit new_user_session_path diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb index e17521e1d02..0f8daaf8e15 100644 --- a/spec/features/task_lists_spec.rb +++ b/spec/features/task_lists_spec.rb @@ -69,13 +69,7 @@ RSpec.describe 'Task Lists', :js do wait_for_requests expect(page).to have_selector(".md .task-list .task-list-item .task-list-item-checkbox") - end - - it_behaves_like 'page with comment and close button', 'Close issue' do - def setup - visit_issue(project, issue) - wait_for_requests - end + expect(page).to have_selector('.btn-close') end it 'is only editable by author' do diff --git a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb index 31e29810c65..900cd72c17f 100644 --- a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb +++ b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb @@ -23,7 +23,7 @@ RSpec.describe 'User uploads avatar to profile' do expect(user.reload.avatar.file).to exist end - it 'their new avatar is immediately visible in the header', :js do + it 'their new avatar is immediately visible in the header and setting sidebar', :js do find('.js-user-avatar-input', visible: false).set(avatar_file_path) click_button 'Set new profile picture' @@ -33,5 +33,6 @@ RSpec.describe 'User uploads avatar to profile' do data_uri = find('.avatar-image .avatar')['src'] expect(page.find('.header-user-avatar')['src']).to eq data_uri + expect(page.find('[data-testid="sidebar-user-avatar"]')['src']).to eq data_uri end end diff --git a/spec/features/user_can_display_performance_bar_spec.rb b/spec/features/user_can_display_performance_bar_spec.rb index 9c67523f88f..b8f41925156 100644 --- a/spec/features/user_can_display_performance_bar_spec.rb +++ b/spec/features/user_can_display_performance_bar_spec.rb @@ -49,6 +49,10 @@ RSpec.describe 'User can display performance bar', :js do let(:group) { create(:group) } + before do + allow(GitlabPerformanceBarStatsWorker).to receive(:perform_in) + end + context 'when user is logged-out' do before do visit root_path @@ -97,6 +101,26 @@ RSpec.describe 'User can display performance bar', :js do it_behaves_like 'performance bar is enabled by default in development' it_behaves_like 'performance bar can be displayed' + + it 'does not show Stats link by default' do + find('body').native.send_keys('pb') + + expect(page).not_to have_link('Stats', visible: :all) + end + + context 'when GITLAB_PERFORMANCE_BAR_STATS_URL environment variable is set' do + let(:stats_url) { 'https://log.gprd.gitlab.net/app/dashboards#/view/' } + + before do + stub_env('GITLAB_PERFORMANCE_BAR_STATS_URL', stats_url) + end + + it 'shows Stats link' do + find('body').native.send_keys('pb') + + expect(page).to have_link('Stats', href: stats_url, visible: :all) + end + end end end end diff --git a/spec/finders/admin/plans_finder_spec.rb b/spec/finders/admin/plans_finder_spec.rb new file mode 100644 index 00000000000..9ea5944147c --- /dev/null +++ b/spec/finders/admin/plans_finder_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Admin::PlansFinder do + let_it_be(:plan1) { create(:plan, name: 'plan1') } + let_it_be(:plan2) { create(:plan, name: 'plan2') } + + describe '#execute' do + context 'with no params' do + it 'returns all plans' do + found = described_class.new.execute + + expect(found).to match_array([plan1, plan2]) + end + end + + context 'with missing name in params' do + before do + @params = { title: 'plan2' } + end + + it 'returns all plans' do + found = described_class.new(@params).execute + + expect(found).to match_array([plan1, plan2]) + end + end + + context 'with existing name in params' do + before do + @params = { name: 'plan2' } + end + + it 'returns the plan' do + found = described_class.new(@params).execute + + expect(found).to match(plan2) + end + end + + context 'with non-existing name in params' do + before do + @params = { name: 'non-existing-plan' } + end + + it 'returns nil' do + found = described_class.new(@params).execute + + expect(found).to be_nil + end + end + end +end diff --git a/spec/services/boards/list_service_spec.rb b/spec/finders/boards/boards_finder_spec.rb index 7c94332a78d..2249c69df1b 100644 --- a/spec/services/boards/list_service_spec.rb +++ b/spec/finders/boards/boards_finder_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Boards::ListService do +RSpec.describe Boards::BoardsFinder do describe '#execute' do context 'when board parent is a project' do let(:parent) { create(:project) } diff --git a/spec/finders/ci/daily_build_group_report_results_finder_spec.rb b/spec/finders/ci/daily_build_group_report_results_finder_spec.rb index 2a6e44673e3..cf15a00323b 100644 --- a/spec/finders/ci/daily_build_group_report_results_finder_spec.rb +++ b/spec/finders/ci/daily_build_group_report_results_finder_spec.rb @@ -5,10 +5,14 @@ require 'spec_helper' RSpec.describe Ci::DailyBuildGroupReportResultsFinder do describe '#execute' do let_it_be(:project) { create(:project, :private) } - let_it_be(:current_user) { project.owner } + let(:user_without_permission) { create(:user) } + let_it_be(:user_with_permission) { project.owner } let_it_be(:ref_path) { 'refs/heads/master' } let(:limit) { nil } let_it_be(:default_branch) { false } + let(:start_date) { '2020-03-09' } + let(:end_date) { '2020-03-10' } + let(:sort) { true } let_it_be(:rspec_coverage_1) { create_daily_coverage('rspec', 79.0, '2020-03-09') } let_it_be(:karma_coverage_1) { create_daily_coverage('karma', 89.0, '2020-03-09') } @@ -17,24 +21,35 @@ RSpec.describe Ci::DailyBuildGroupReportResultsFinder do let_it_be(:rspec_coverage_3) { create_daily_coverage('rspec', 97.0, '2020-03-11') } let_it_be(:karma_coverage_3) { create_daily_coverage('karma', 99.0, '2020-03-11') } - let(:attributes) do + let(:finder) { described_class.new(params: params, current_user: current_user) } + + let(:params) do { - current_user: current_user, project: project, + coverage: true, ref_path: ref_path, - start_date: '2020-03-09', - end_date: '2020-03-10', - limit: limit + start_date: start_date, + end_date: end_date, + limit: limit, + sort: sort } end - subject(:coverages) do - described_class.new(**attributes).execute - end + subject(:coverages) { finder.execute } + + context 'when params are provided' do + context 'when current user is not allowed to read data' do + let(:current_user) { user_without_permission } + + it 'returns an empty collection' do + expect(coverages).to be_empty + end + end + + context 'when current user is allowed to read data' do + let(:current_user) { user_with_permission } - context 'when ref_path is present' do - context 'when current user is allowed to read build report results' do - it 'returns all matching results within the given date range' do + it 'returns matching coverages within the given date range' do expect(coverages).to match_array([ karma_coverage_2, rspec_coverage_2, @@ -43,37 +58,45 @@ RSpec.describe Ci::DailyBuildGroupReportResultsFinder do ]) end - context 'and limit is specified' do + context 'when ref_path is nil' do + let(:default_branch) { true } + let(:ref_path) { nil } + + it 'returns coverages for the default branch' do + rspec_coverage_4 = create_daily_coverage('rspec', 66.0, '2020-03-10') + + expect(coverages).to contain_exactly(rspec_coverage_4) + end + end + + context 'when limit is specified' do let(:limit) { 2 } - it 'returns limited number of matching results within the given date range' do + it 'returns limited number of matching coverages within the given date range' do expect(coverages).to match_array([ karma_coverage_2, rspec_coverage_2 ]) end end - end - - context 'when current user is not allowed to read build report results' do - let(:current_user) { create(:user) } - - it 'returns an empty result' do - expect(coverages).to be_empty - end - end - end - - context 'when ref_path query parameter is not present' do - let(:ref_path) { nil } - context 'when records with cover data from the default branch exist' do - let(:default_branch) { true } - - it 'returns records with default_branch:true, irrespective of ref_path' do - rspec_coverage_4 = create_daily_coverage('rspec', 66.0, '2020-03-10') - - expect(coverages).to contain_exactly(rspec_coverage_4) + context 'when provided dates are nil' do + let(:start_date) { nil } + let(:end_date) { nil } + let(:rspec_coverage_4) { create_daily_coverage('rspec', 98.0, 91.days.ago.to_date.to_s) } + + it 'returns all coverages from the last 90 days' do + expect(coverages).to match_array( + [ + karma_coverage_3, + rspec_coverage_3, + karma_coverage_2, + rspec_coverage_2, + karma_coverage_1, + rspec_coverage_1 + ] + ) + end end end end diff --git a/spec/finders/ci/testing/daily_build_group_report_results_finder_spec.rb b/spec/finders/ci/testing/daily_build_group_report_results_finder_spec.rb deleted file mode 100644 index a703f3b800c..00000000000 --- a/spec/finders/ci/testing/daily_build_group_report_results_finder_spec.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Ci::Testing::DailyBuildGroupReportResultsFinder do - describe '#execute' do - let_it_be(:project) { create(:project, :private) } - let(:user_without_permission) { create(:user) } - let_it_be(:user_with_permission) { project.owner } - let_it_be(:ref_path) { 'refs/heads/master' } - let(:limit) { nil } - let_it_be(:default_branch) { false } - let(:start_date) { '2020-03-09' } - let(:end_date) { '2020-03-10' } - let(:sort) { true } - - let_it_be(:rspec_coverage_1) { create_daily_coverage('rspec', 79.0, '2020-03-09') } - let_it_be(:karma_coverage_1) { create_daily_coverage('karma', 89.0, '2020-03-09') } - let_it_be(:rspec_coverage_2) { create_daily_coverage('rspec', 95.0, '2020-03-10') } - let_it_be(:karma_coverage_2) { create_daily_coverage('karma', 92.0, '2020-03-10') } - let_it_be(:rspec_coverage_3) { create_daily_coverage('rspec', 97.0, '2020-03-11') } - let_it_be(:karma_coverage_3) { create_daily_coverage('karma', 99.0, '2020-03-11') } - - let(:finder) { described_class.new(params: params, current_user: current_user) } - - let(:params) do - { - project: project, - coverage: true, - ref_path: ref_path, - start_date: start_date, - end_date: end_date, - limit: limit, - sort: sort - } - end - - subject(:coverages) { finder.execute } - - context 'when params are provided' do - context 'when current user is not allowed to read data' do - let(:current_user) { user_without_permission } - - it 'returns an empty collection' do - expect(coverages).to be_empty - end - end - - context 'when current user is allowed to read data' do - let(:current_user) { user_with_permission } - - it 'returns matching coverages within the given date range' do - expect(coverages).to match_array([ - karma_coverage_2, - rspec_coverage_2, - karma_coverage_1, - rspec_coverage_1 - ]) - end - - context 'when ref_path is nil' do - let(:default_branch) { true } - let(:ref_path) { nil } - - it 'returns coverages for the default branch' do - rspec_coverage_4 = create_daily_coverage('rspec', 66.0, '2020-03-10') - - expect(coverages).to contain_exactly(rspec_coverage_4) - end - end - - context 'when limit is specified' do - let(:limit) { 2 } - - it 'returns limited number of matching coverages within the given date range' do - expect(coverages).to match_array([ - karma_coverage_2, - rspec_coverage_2 - ]) - end - end - end - end - end - - private - - def create_daily_coverage(group_name, coverage, date) - create( - :ci_daily_build_group_report_result, - project: project, - ref_path: ref_path || 'feature-branch', - group_name: group_name, - data: { 'coverage' => coverage }, - date: date, - default_branch: default_branch - ) - end -end diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index 33b8a5954ae..b794ab626bf 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -179,33 +179,54 @@ RSpec.describe IssuesFinder do end end - context 'filtering by author ID' do - let(:params) { { author_id: user2.id } } + context 'filtering by author' do + context 'by author ID' do + let(:params) { { author_id: user2.id } } - it 'returns issues created by that user' do - expect(issues).to contain_exactly(issue3) + it 'returns issues created by that user' do + expect(issues).to contain_exactly(issue3) + end end - end - context 'filtering by not author ID' do - let(:params) { { not: { author_id: user2.id } } } + context 'using OR' do + let(:issue6) { create(:issue, project: project2) } + let(:params) { { or: { author_username: [issue3.author.username, issue6.author.username] } } } + + it 'returns issues created by any of the given users' do + expect(issues).to contain_exactly(issue3, issue6) + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(or_issuable_queries: false) + end - it 'returns issues not created by that user' do - expect(issues).to contain_exactly(issue1, issue2, issue4, issue5) + it 'does not add any filter' do + expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5, issue6) + end + end 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 - } + context 'filtering by NOT author ID' do + let(:params) { { not: { author_id: user2.id } } } + + it 'returns issues not created by that user' do + expect(issues).to contain_exactly(issue1, issue2, issue4, issue5) + end end - it 'returns no results' do - expect(issues).to be_empty + 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(issues).to be_empty + end end end diff --git a/spec/finders/merge_request_target_project_finder_spec.rb b/spec/finders/merge_request_target_project_finder_spec.rb index dfb4d86fbb6..08fbfd7229a 100644 --- a/spec/finders/merge_request_target_project_finder_spec.rb +++ b/spec/finders/merge_request_target_project_finder_spec.rb @@ -16,13 +16,22 @@ RSpec.describe MergeRequestTargetProjectFinder do expect(finder.execute).to contain_exactly(base_project, other_fork, forked_project) end - it 'does not include projects that have merge requests turned off' do + it 'does not include projects that have merge requests turned off by default' do other_fork.project_feature.update!(merge_requests_access_level: ProjectFeature::DISABLED) base_project.project_feature.update!(merge_requests_access_level: ProjectFeature::DISABLED) expect(finder.execute).to contain_exactly(forked_project) end + it 'includes projects that have merge requests turned off by default with a more-permissive project feature' do + finder = described_class.new(current_user: user, source_project: forked_project, project_feature: :repository) + + other_fork.project_feature.update!(merge_requests_access_level: ProjectFeature::DISABLED) + base_project.project_feature.update!(merge_requests_access_level: ProjectFeature::DISABLED) + + expect(finder.execute).to contain_exactly(base_project, other_fork, forked_project) + end + it 'does not contain archived projects' do base_project.update!(archived: true) diff --git a/spec/finders/merge_requests/oldest_per_commit_finder_spec.rb b/spec/finders/merge_requests/oldest_per_commit_finder_spec.rb index 4e9d021fa5d..4724a8eb5c7 100644 --- a/spec/finders/merge_requests/oldest_per_commit_finder_spec.rb +++ b/spec/finders/merge_requests/oldest_per_commit_finder_spec.rb @@ -6,12 +6,20 @@ RSpec.describe MergeRequests::OldestPerCommitFinder do describe '#execute' do it 'returns a Hash mapping commit SHAs to their oldest merge requests' do project = create(:project) + sha1 = Digest::SHA1.hexdigest('foo') + sha2 = Digest::SHA1.hexdigest('bar') + sha3 = Digest::SHA1.hexdigest('baz') mr1 = create(:merge_request, :merged, target_project: project) mr2 = create(:merge_request, :merged, target_project: project) + mr3 = create( + :merge_request, + :merged, + target_project: project, + merge_commit_sha: sha3 + ) + mr1_diff = create(:merge_request_diff, merge_request: mr1) mr2_diff = create(:merge_request_diff, merge_request: mr2) - sha1 = Digest::SHA1.hexdigest('foo') - sha2 = Digest::SHA1.hexdigest('bar') create(:merge_request_diff_commit, merge_request_diff: mr1_diff, sha: sha1) create(:merge_request_diff_commit, merge_request_diff: mr2_diff, sha: sha1) @@ -22,11 +30,16 @@ RSpec.describe MergeRequests::OldestPerCommitFinder do relative_order: 1 ) - commits = [double(:commit, id: sha1), double(:commit, id: sha2)] + commits = [ + double(:commit, id: sha1), + double(:commit, id: sha2), + double(:commit, id: sha3) + ] expect(described_class.new(project).execute(commits)).to eq( sha1 => mr1, - sha2 => mr2 + sha2 => mr2, + sha3 => mr3 ) end @@ -42,5 +55,45 @@ RSpec.describe MergeRequests::OldestPerCommitFinder do expect(described_class.new(mr.target_project).execute(commits)) .to be_empty end + + it 'includes the merge request for a merge commit' do + project = create(:project) + sha = Digest::SHA1.hexdigest('foo') + mr = create( + :merge_request, + :merged, + target_project: project, + merge_commit_sha: sha + ) + + commits = [double(:commit, id: sha)] + + # This expectation is set so we're certain that the merge commit SHAs (if + # a matching merge request is found) aren't also used for finding MRs + # according to diffs. + expect(MergeRequestDiffCommit) + .not_to receive(:oldest_merge_request_id_per_commit) + + expect(described_class.new(project).execute(commits)).to eq(sha => mr) + end + + it 'includes the oldest merge request when a merge commit is present in a newer merge request' do + project = create(:project) + sha = Digest::SHA1.hexdigest('foo') + mr1 = create( + :merge_request, + :merged, + target_project: project, merge_commit_sha: sha + ) + + mr2 = create(:merge_request, :merged, target_project: project) + mr_diff = create(:merge_request_diff, merge_request: mr2) + + create(:merge_request_diff_commit, merge_request_diff: mr_diff, sha: sha) + + commits = [double(:commit, id: sha)] + + expect(described_class.new(project).execute(commits)).to eq(sha => mr1) + end end end diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb index 6fdfe780463..b3000498bb6 100644 --- a/spec/finders/merge_requests_finder_spec.rb +++ b/spec/finders/merge_requests_finder_spec.rb @@ -41,30 +41,51 @@ RSpec.describe MergeRequestsFinder do expect(merge_requests).to contain_exactly(merge_request1) end - it 'filters by nonexistent author ID and MR term using CTE for search' do - params = { - author_id: 'does-not-exist', - search: 'git', - attempt_group_search_optimizations: true - } + context 'filtering by author' do + subject(:merge_requests) { described_class.new(user, params).execute } - merge_requests = described_class.new(user, params).execute + context 'using OR' do + let(:params) { { or: { author_username: [merge_request1.author.username, merge_request2.author.username] } } } - expect(merge_requests).to be_empty - end + before do + merge_request1.update!(author: create(:user)) + merge_request2.update!(author: create(:user)) + end + + it 'returns merge requests created by any of the given users' do + expect(merge_requests).to contain_exactly(merge_request1, merge_request2) + end - context 'filtering by not author ID' do - let(:params) { { not: { author_id: user2.id } } } + context 'when feature flag is disabled' do + before do + stub_feature_flags(or_issuable_queries: false) + end - before do - merge_request2.update!(author: user2) - merge_request3.update!(author: user2) + it 'does not add any filter' do + expect(merge_requests).to contain_exactly(merge_request1, merge_request2, merge_request3, merge_request4, merge_request5) + end + end end - it 'returns merge requests not created by that user' do - merge_requests = described_class.new(user, params).execute + context 'with nonexistent author ID and MR term using CTE for search' do + let(:params) { { author_id: 'does-not-exist', search: 'git', attempt_group_search_optimizations: true } } + + it 'returns no results' do + expect(merge_requests).to be_empty + end + end - expect(merge_requests).to contain_exactly(merge_request1, merge_request4, merge_request5) + context 'filtering by not author ID' do + let(:params) { { not: { author_id: user2.id } } } + + before do + merge_request2.update!(author: user2) + merge_request3.update!(author: user2) + end + + it 'returns merge requests not created by that user' do + expect(merge_requests).to contain_exactly(merge_request1, merge_request4, merge_request5) + end end end diff --git a/spec/finders/namespaces/projects_finder_spec.rb b/spec/finders/namespaces/projects_finder_spec.rb new file mode 100644 index 00000000000..0f48aa6a9f4 --- /dev/null +++ b/spec/finders/namespaces/projects_finder_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Namespaces::ProjectsFinder do + let_it_be(:current_user) { create(:user) } + let_it_be(:namespace) { create(:group, :public) } + let_it_be(:subgroup) { create(:group, parent: namespace) } + let_it_be(:project_1) { create(:project, :public, group: namespace, path: 'project', name: 'Project') } + let_it_be(:project_2) { create(:project, :public, group: namespace, path: 'test-project', name: 'Test Project') } + let_it_be(:project_3) { create(:project, :public, path: 'sub-test-project', group: subgroup, name: 'Sub Test Project') } + let_it_be(:project_4) { create(:project, :public, path: 'test-project-2', group: namespace, name: 'Test Project 2') } + + let(:params) { {} } + + let(:finder) { described_class.new(namespace: namespace, params: params, current_user: current_user) } + + subject(:projects) { finder.execute } + + describe '#execute' do + context 'without a namespace' do + let(:namespace) { nil } + + it 'returns an empty array' do + expect(projects).to be_empty + end + end + + context 'with a namespace' do + it 'returns the project for the namespace' do + expect(projects).to contain_exactly(project_1, project_2, project_4) + end + + context 'when include_subgroups is provided' do + let(:params) { { include_subgroups: true } } + + it 'returns all projects for the namespace' do + expect(projects).to contain_exactly(project_1, project_2, project_3, project_4) + end + + context 'when ids are provided' do + let(:params) { { include_subgroups: true, ids: [project_3.id] } } + + it 'returns all projects for the ids' do + expect(projects).to contain_exactly(project_3) + end + end + end + + context 'when ids are provided' do + let(:params) { { ids: [project_1.id] } } + + it 'returns all projects for the ids' do + expect(projects).to contain_exactly(project_1) + end + end + + context 'when sort is similarity' do + let(:params) { { sort: :similarity, search: 'test' } } + + it 'returns projects by similarity' do + expect(projects).to contain_exactly(project_2, project_4) + end + end + + context 'when search parameter is missing' do + let(:params) { { sort: :similarity } } + + it 'returns all projects' do + expect(projects).to contain_exactly(project_1, project_2, project_4) + end + end + + context 'when sort parameter is missing' do + let(:params) { { search: 'test' } } + + it 'returns matching projects' do + expect(projects).to contain_exactly(project_2, project_4) + end + end + end + end +end diff --git a/spec/finders/packages/group_packages_finder_spec.rb b/spec/finders/packages/group_packages_finder_spec.rb index 445482a5a96..d6daf73aba2 100644 --- a/spec/finders/packages/group_packages_finder_spec.rb +++ b/spec/finders/packages/group_packages_finder_spec.rb @@ -122,7 +122,7 @@ RSpec.describe Packages::GroupPackagesFinder do end context 'when there are processing packages' do - let_it_be(:package4) { create(:nuget_package, project: project, name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) } + let_it_be(:package4) { create(:nuget_package, project: project, name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) } it { is_expected.to match_array([package1, package2]) } end diff --git a/spec/finders/packages/npm/package_finder_spec.rb b/spec/finders/packages/npm/package_finder_spec.rb index 78c23971f92..f021d800f31 100644 --- a/spec/finders/packages/npm/package_finder_spec.rb +++ b/spec/finders/packages/npm/package_finder_spec.rb @@ -2,39 +2,139 @@ require 'spec_helper' RSpec.describe ::Packages::Npm::PackageFinder do - let(:package) { create(:npm_package) } + let_it_be_with_reload(:project) { create(:project)} + let_it_be(:package) { create(:npm_package, project: project) } + let(:project) { package.project } let(:package_name) { package.name } - describe '#execute!' do - subject { described_class.new(project, package_name).execute } + shared_examples 'accepting a namespace for' do |example_name| + before do + project.update!(namespace: namespace) + end + + context 'that is a group' do + let_it_be(:namespace) { create(:group) } + + it_behaves_like example_name + + context 'within another group' do + let_it_be(:subgroup) { create(:group, parent: namespace) } + + before do + project.update!(namespace: subgroup) + end + + it_behaves_like example_name + end + end + + context 'that is a user namespace' do + let_it_be(:user) { create(:user) } + let_it_be(:namespace) { user.namespace } + + it_behaves_like example_name + end + end + + describe '#execute' do + shared_examples 'finding packages by name' do + it { is_expected.to eq([package]) } + + context 'with unknown package name' do + let(:package_name) { 'baz' } + + it { is_expected.to be_empty } + end + end + + subject { finder.execute } + + context 'with a project' do + let(:finder) { described_class.new(package_name, project: project) } - it { is_expected.to eq([package]) } + it_behaves_like 'finding packages by name' - context 'with unknown package name' do - let(:package_name) { 'baz' } + context 'set to nil' do + let(:project) { nil } - it { is_expected.to be_empty } + it { is_expected.to be_empty } + end end - context 'with nil project' do - let(:project) { nil } + context 'with a namespace' do + let(:finder) { described_class.new(package_name, namespace: namespace) } + + it_behaves_like 'accepting a namespace for', 'finding packages by name' + + context 'set to nil' do + let_it_be(:namespace) { nil } - it { is_expected.to be_empty } + it { is_expected.to be_empty } + end end end describe '#find_by_version' do let(:version) { package.version } - subject { described_class.new(project, package.name).find_by_version(version) } + subject { finder.find_by_version(version) } + + shared_examples 'finding packages by version' do + it { is_expected.to eq(package) } + + context 'with unknown version' do + let(:version) { 'foobar' } + + it { is_expected.to be_nil } + end + end + + context 'with a project' do + let(:finder) { described_class.new(package_name, project: project) } + + it_behaves_like 'finding packages by version' + end + + context 'with a namespace' do + let(:finder) { described_class.new(package_name, namespace: namespace) } + + it_behaves_like 'accepting a namespace for', 'finding packages by version' + end + end + + describe '#last' do + subject { finder.last } + + shared_examples 'finding package by last' do + it { is_expected.to eq(package) } + end + + context 'with a project' do + let(:finder) { described_class.new(package_name, project: project) } + + it_behaves_like 'finding package by last' + end + + context 'with a namespace' do + let(:finder) { described_class.new(package_name, namespace: namespace) } + + it_behaves_like 'accepting a namespace for', 'finding package by last' - it { is_expected.to eq(package) } + context 'with duplicate packages' do + let_it_be(:namespace) { create(:group) } + let_it_be(:subgroup1) { create(:group, parent: namespace) } + let_it_be(:subgroup2) { create(:group, parent: namespace) } + let_it_be(:project2) { create(:project, namespace: subgroup2) } + let_it_be(:package2) { create(:npm_package, name: package.name, project: project2) } - context 'with unknown version' do - let(:version) { 'foobar' } + before do + project.update!(namespace: subgroup1) + end - it { is_expected.to be_nil } + # the most recent one is returned + it { is_expected.to eq(package2) } + end end end end diff --git a/spec/finders/packages/package_finder_spec.rb b/spec/finders/packages/package_finder_spec.rb index ef07e7575d1..e8c7404a612 100644 --- a/spec/finders/packages/package_finder_spec.rb +++ b/spec/finders/packages/package_finder_spec.rb @@ -14,7 +14,7 @@ RSpec.describe ::Packages::PackageFinder do it { is_expected.to eq(maven_package) } context 'processing packages' do - let_it_be(:nuget_package) { create(:nuget_package, project: project, name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) } + let_it_be(:nuget_package) { create(:nuget_package, project: project, name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) } let(:package_id) { nuget_package.id } it 'are not returned' do diff --git a/spec/finders/packages/packages_finder_spec.rb b/spec/finders/packages/packages_finder_spec.rb index 6e92616bafa..0add77a8478 100644 --- a/spec/finders/packages/packages_finder_spec.rb +++ b/spec/finders/packages/packages_finder_spec.rb @@ -76,7 +76,7 @@ RSpec.describe ::Packages::PackagesFinder do end context 'with processing packages' do - let_it_be(:nuget_package) { create(:nuget_package, project: project, name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) } + let_it_be(:nuget_package) { create(:nuget_package, project: project, name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) } it { is_expected.to match_array([conan_package, maven_package]) } end diff --git a/spec/finders/projects/groups_finder_spec.rb b/spec/finders/projects/groups_finder_spec.rb new file mode 100644 index 00000000000..89d4edaec7c --- /dev/null +++ b/spec/finders/projects/groups_finder_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::GroupsFinder do + describe '#execute' do + let_it_be(:user) { create(:user) } + let_it_be(:root_group) { create(:group, :public) } + let_it_be(:project_group) { create(:group, :public, parent: root_group) } + let_it_be(:shared_group_with_dev_access) { create(:group, :private, parent: root_group) } + let_it_be(:shared_group_with_reporter_access) { create(:group, :private) } + + let_it_be(:public_project) { create(:project, :public, group: project_group) } + let_it_be(:private_project) { create(:project, :private, group: project_group) } + + before_all do + [public_project, private_project].each do |project| + create(:project_group_link, :developer, group: shared_group_with_dev_access, project: project) + create(:project_group_link, :reporter, group: shared_group_with_reporter_access, project: project) + end + end + + let(:params) { {} } + let(:current_user) { user } + let(:finder) { described_class.new(project: project, current_user: current_user, params: params) } + + subject { finder.execute } + + shared_examples 'finding related groups' do + it 'returns ancestor groups for this project' do + is_expected.to match_array([project_group, root_group]) + end + + context 'when the project does not belong to any group' do + before do + allow(project).to receive(:group) { nil } + end + + it { is_expected.to eq([]) } + end + + context 'when shared groups option is on' do + let(:params) { { with_shared: true } } + + it 'returns ancestor and all shared groups' do + is_expected.to match_array([project_group, root_group, shared_group_with_dev_access, shared_group_with_reporter_access]) + end + + context 'when shared_min_access_level is developer' do + let(:params) { super().merge(shared_min_access_level: Gitlab::Access::DEVELOPER) } + + it 'returns ancestor and shared groups with at least developer access' do + is_expected.to match_array([project_group, root_group, shared_group_with_dev_access]) + end + end + end + + context 'when skip group option is on' do + let(:params) { { skip_groups: [project_group.id] } } + + it 'excludes provided groups' do + is_expected.to match_array([root_group]) + end + end + end + + context 'Public project' do + it_behaves_like 'finding related groups' do + let(:project) { public_project } + + context 'when user is not authorized' do + let(:current_user) { nil } + + it 'returns ancestor groups for this project' do + is_expected.to match_array([project_group, root_group]) + end + end + end + end + + context 'Private project' do + it_behaves_like 'finding related groups' do + let(:project) { private_project } + + before do + project.add_developer(user) + end + + context 'when user is not authorized' do + let(:current_user) { nil } + + it { is_expected.to eq([]) } + end + end + end + + context 'Missing project' do + let(:project) { nil } + + it { is_expected.to eq([]) } + end + end +end diff --git a/spec/finders/repositories/changelog_commits_finder_spec.rb b/spec/finders/repositories/changelog_commits_finder_spec.rb new file mode 100644 index 00000000000..8665d36144a --- /dev/null +++ b/spec/finders/repositories/changelog_commits_finder_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Repositories::ChangelogCommitsFinder do + let_it_be(:project) { create(:project, :repository) } + + describe '#each_page' do + it 'only yields commits with the given trailer' do + finder = described_class.new( + project: project, + from: '570e7b2abdd848b95f2f578043fc23bd6f6fd24d', + to: 'c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd' + ) + + commits = finder.each_page('Signed-off-by').to_a.flatten + + expect(commits.length).to eq(1) + expect(commits.first.id).to eq('5937ac0a7beb003549fc5fd26fc247adbce4a52e') + expect(commits.first.trailers).to eq( + 'Signed-off-by' => 'Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>' + ) + end + + it 'ignores commits that are reverted' do + # This range of commits is found on the branch + # https://gitlab.com/gitlab-org/gitlab-test/-/commits/trailers. + finder = described_class.new( + project: project, + from: 'ddd0f15ae83993f5cb66a927a28673882e99100b', + to: '694e6c2f08cad00d183682d9dede99615998a630' + ) + + commits = finder.each_page('Changelog').to_a.flatten + + expect(commits).to be_empty + end + + it 'includes revert commits if they have a trailer' do + finder = described_class.new( + project: project, + from: 'ddd0f15ae83993f5cb66a927a28673882e99100b', + to: 'f0a5ed60d24c98ec6d00ac010c1f3f01ee0a8373' + ) + + initial_commit = project.commit('ed2e92bf50b3da2c7cbbab053f4977a4ecbd109a') + revert_commit = project.commit('f0a5ed60d24c98ec6d00ac010c1f3f01ee0a8373') + + commits = finder.each_page('Changelog').to_a.flatten + + expect(commits).to eq([revert_commit, initial_commit]) + end + + it 'supports paginating of commits' do + finder = described_class.new( + project: project, + from: 'c1acaa58bbcbc3eafe538cb8274ba387047b69f8', + to: '5937ac0a7beb003549fc5fd26fc247adbce4a52e', + per_page: 1 + ) + + commits = finder.each_page('Signed-off-by') + + expect(commits.count).to eq(4) + end + end + + describe '#revert_commit_sha' do + let(:finder) { described_class.new(project: project, from: 'a', to: 'b') } + + it 'returns the SHA of a reverted commit' do + commit = double( + :commit, + description: 'This reverts commit 152c03af1b09f50fa4b567501032b106a3a81ff3.' + ) + + expect(finder.send(:revert_commit_sha, commit)) + .to eq('152c03af1b09f50fa4b567501032b106a3a81ff3') + end + + it 'returns nil when the commit is not a revert commit' do + commit = double(:commit, description: 'foo') + + expect(finder.send(:revert_commit_sha, commit)).to be_nil + end + + it 'returns nil when the commit has no description' do + commit = double(:commit, description: nil) + + expect(finder.send(:revert_commit_sha, commit)).to be_nil + end + end +end diff --git a/spec/finders/repositories/commits_with_trailer_finder_spec.rb b/spec/finders/repositories/commits_with_trailer_finder_spec.rb deleted file mode 100644 index 0c457aae340..00000000000 --- a/spec/finders/repositories/commits_with_trailer_finder_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Repositories::CommitsWithTrailerFinder do - let(:project) { create(:project, :repository) } - - describe '#each_page' do - it 'only yields commits with the given trailer' do - finder = described_class.new( - project: project, - from: '570e7b2abdd848b95f2f578043fc23bd6f6fd24d', - to: 'c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd' - ) - - commits = finder.each_page('Signed-off-by').to_a.flatten - - expect(commits.length).to eq(1) - expect(commits.first.id).to eq('5937ac0a7beb003549fc5fd26fc247adbce4a52e') - expect(commits.first.trailers).to eq( - 'Signed-off-by' => 'Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>' - ) - end - - it 'supports paginating of commits' do - finder = described_class.new( - project: project, - from: 'c1acaa58bbcbc3eafe538cb8274ba387047b69f8', - to: '5937ac0a7beb003549fc5fd26fc247adbce4a52e', - per_page: 1 - ) - - commits = finder.each_page('Signed-off-by') - - expect(commits.count).to eq(4) - end - end -end diff --git a/spec/finders/repositories/previous_tag_finder_spec.rb b/spec/finders/repositories/previous_tag_finder_spec.rb index 7cc33d11baf..b332dd158d1 100644 --- a/spec/finders/repositories/previous_tag_finder_spec.rb +++ b/spec/finders/repositories/previous_tag_finder_spec.rb @@ -12,16 +12,20 @@ RSpec.describe Repositories::PreviousTagFinder do tag1 = double(:tag1, name: 'v1.0.0') tag2 = double(:tag2, name: 'v1.1.0') tag3 = double(:tag3, name: 'v2.0.0') - tag4 = double(:tag4, name: '1.0.0') + tag4 = double(:tag4, name: '0.9.0') + tag5 = double(:tag5, name: 'v0.8.0-pre1') + tag6 = double(:tag6, name: 'v0.7.0') allow(project.repository) .to receive(:tags) - .and_return([tag1, tag3, tag2, tag4]) + .and_return([tag1, tag3, tag2, tag4, tag5, tag6]) expect(finder.execute('2.1.0')).to eq(tag3) expect(finder.execute('2.0.0')).to eq(tag2) expect(finder.execute('1.5.0')).to eq(tag2) expect(finder.execute('1.0.1')).to eq(tag1) + expect(finder.execute('1.0.0')).to eq(tag4) + expect(finder.execute('0.9.0')).to eq(tag6) end end diff --git a/spec/finders/security/license_compliance_jobs_finder_spec.rb b/spec/finders/security/license_compliance_jobs_finder_spec.rb index 3066912df12..de4a7eb2c12 100644 --- a/spec/finders/security/license_compliance_jobs_finder_spec.rb +++ b/spec/finders/security/license_compliance_jobs_finder_spec.rb @@ -15,10 +15,9 @@ RSpec.describe Security::LicenseComplianceJobsFinder do let!(:container_scanning_build) { create(:ci_build, :container_scanning, pipeline: pipeline) } let!(:dast_build) { create(:ci_build, :dast, pipeline: pipeline) } let!(:license_scanning_build) { create(:ci_build, :license_scanning, pipeline: pipeline) } - let!(:license_management_build) { create(:ci_build, :license_management, pipeline: pipeline) } - it 'returns only the license_scanning jobs' do - is_expected.to contain_exactly(license_scanning_build, license_management_build) + it 'returns only the license_scanning job' do + is_expected.to contain_exactly(license_scanning_build) end end end diff --git a/spec/finders/users_finder_spec.rb b/spec/finders/users_finder_spec.rb index d9cc71106d5..b0f8b803141 100644 --- a/spec/finders/users_finder_spec.rb +++ b/spec/finders/users_finder_spec.rb @@ -12,7 +12,7 @@ RSpec.describe UsersFinder do it 'returns all users' do users = described_class.new(user).execute - expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user, internal_user, admin_user) + expect(users).to contain_exactly(user, normal_user, blocked_user, external_user, omniauth_user, internal_user, admin_user) end it 'filters by username' do @@ -48,12 +48,18 @@ RSpec.describe UsersFinder do it 'filters by active users' do users = described_class.new(user, active: true).execute - expect(users).to contain_exactly(user, normal_user, omniauth_user, admin_user) + expect(users).to contain_exactly(user, normal_user, external_user, omniauth_user, admin_user) end - it 'returns no external users' do + it 'filters by external users' do users = described_class.new(user, external: true).execute + expect(users).to contain_exactly(external_user) + end + + it 'filters by non external users' do + users = described_class.new(user, non_external: true).execute + expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user, internal_user, admin_user) end @@ -71,7 +77,7 @@ RSpec.describe UsersFinder do it 'filters by non internal users' do users = described_class.new(user, non_internal: true).execute - expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user, admin_user) + expect(users).to contain_exactly(user, normal_user, external_user, blocked_user, omniauth_user, admin_user) end it 'does not filter by custom attributes' do @@ -80,18 +86,18 @@ RSpec.describe UsersFinder do custom_attributes: { foo: 'bar' } ).execute - expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user, internal_user, admin_user) + expect(users).to contain_exactly(user, normal_user, blocked_user, external_user, omniauth_user, internal_user, admin_user) end it 'orders returned results' do users = described_class.new(user, sort: 'id_asc').execute - expect(users).to eq([normal_user, admin_user, blocked_user, omniauth_user, internal_user, user]) + expect(users).to eq([normal_user, admin_user, blocked_user, external_user, omniauth_user, internal_user, user]) end it 'does not filter by admins' do users = described_class.new(user, admins: true).execute - expect(users).to contain_exactly(user, normal_user, admin_user, blocked_user, omniauth_user, internal_user) + expect(users).to contain_exactly(user, normal_user, external_user, admin_user, blocked_user, omniauth_user, internal_user) end end diff --git a/spec/fixtures/api/schemas/entities/test_suite_comparer.json b/spec/fixtures/api/schemas/entities/test_suite_comparer.json index ecb331ae013..ac001ef8843 100644 --- a/spec/fixtures/api/schemas/entities/test_suite_comparer.json +++ b/spec/fixtures/api/schemas/entities/test_suite_comparer.json @@ -26,7 +26,14 @@ "existing_failures": { "type": "array", "items": { "$ref": "test_case.json" } }, "new_errors": { "type": "array", "items": { "$ref": "test_case.json" } }, "resolved_errors": { "type": "array", "items": { "$ref": "test_case.json" } }, - "existing_errors": { "type": "array", "items": { "$ref": "test_case.json" } } + "existing_errors": { "type": "array", "items": { "$ref": "test_case.json" } }, + "suite_errors": { + "type": ["object", "null"], + "properties": { + "head": { "type": ["string", "null"] }, + "base": { "type": ["string", "null"] } + } + } }, "additionalProperties": false } diff --git a/spec/fixtures/api/schemas/public_api/v4/packages/composer/index.json b/spec/fixtures/api/schemas/public_api/v4/packages/composer/index.json index 2245b39cabe..81a222b922d 100644 --- a/spec/fixtures/api/schemas/public_api/v4/packages/composer/index.json +++ b/spec/fixtures/api/schemas/public_api/v4/packages/composer/index.json @@ -1,6 +1,6 @@ { "type": "object", - "required": ["packages", "provider-includes", "providers-url"], + "required": ["packages", "provider-includes", "providers-url", "metadata-url"], "properties": { "packages": { "type": "array", @@ -9,6 +9,9 @@ "providers-url": { "type": "string" }, + "metadata-url": { + "type": "string" + }, "provider-includes": { "type": "object", "required": ["p/%hash%.json"], diff --git a/spec/fixtures/api/schemas/public_api/v4/pipeline.json b/spec/fixtures/api/schemas/public_api/v4/pipeline.json index f83844a115d..7e553f9e5de 100644 --- a/spec/fixtures/api/schemas/public_api/v4/pipeline.json +++ b/spec/fixtures/api/schemas/public_api/v4/pipeline.json @@ -1,10 +1,13 @@ { "type": "object", - "required": ["id", "sha", "ref", "status", "created_at", "updated_at", "web_url"], + "required": ["id", "project_id", "sha", "ref", "status", "created_at", "updated_at", "web_url"], "properties": { "id": { "type": "integer" }, + "project_id": { + "type": "integer" + }, "sha": { "type": "string" }, diff --git a/spec/fixtures/dependency_proxy/manifest b/spec/fixtures/dependency_proxy/manifest index a899d05d697..ed543883d60 100644 --- a/spec/fixtures/dependency_proxy/manifest +++ b/spec/fixtures/dependency_proxy/manifest @@ -1,38 +1,16 @@ { - "schemaVersion": 1, - "name": "library/alpine", - "tag": "latest", - "architecture": "amd64", - "fsLayers": [ + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "config": { + "mediaType": "application/vnd.docker.container.image.v1+json", + "size": 1472, + "digest": "sha256:7731472c3f2a25edbb9c085c78f42ec71259f2b83485aa60648276d408865839" + }, + "layers": [ { - "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4" - }, - { - "blobSum": "sha256:188c0c94c7c576fff0792aca7ec73d67a2f7f4cb3a6e53a84559337260b36964" - } - ], - "history": [ - { - "v1Compatibility": "{\"architecture\":\"amd64\",\"config\":{\"Hostname\":\"\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"/bin/sh\"],\"ArgsEscaped\":true,\"Image\":\"sha256:3543079adc6fb5170279692361be8b24e89ef1809a374c1b4429e1d560d1459c\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"container\":\"8c59eb170e19b8c3768b8d06c91053b0debf4a6fa6a452df394145fe9b885ea5\",\"container_config\":{\"Hostname\":\"8c59eb170e19\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) \",\"CMD [\\\"/bin/sh\\\"]\"],\"ArgsEscaped\":true,\"Image\":\"sha256:3543079adc6fb5170279692361be8b24e89ef1809a374c1b4429e1d560d1459c\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":{}},\"created\":\"2020-10-22T02:19:24.499382102Z\",\"docker_version\":\"18.09.7\",\"id\":\"c5f1aab5bb88eaf1aa62bea08ea6654547d43fd4d15b1a476c77e705dd5385ba\",\"os\":\"linux\",\"parent\":\"dc0b50cc52bc340d7848a62cfe8a756f4420592f4984f7a680ef8f9d258176ed\",\"throwaway\":true}" - }, - { - "v1Compatibility": "{\"id\":\"dc0b50cc52bc340d7848a62cfe8a756f4420592f4984f7a680ef8f9d258176ed\",\"created\":\"2020-10-22T02:19:24.33416307Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ADD file:f17f65714f703db9012f00e5ec98d0b2541ff6147c2633f7ab9ba659d0c507f4 in / \"]}}" - } - ], - "signatures": [ - { - "header": { - "jwk": { - "crv": "P-256", - "kid": "XOTE:DZ4C:YBPJ:3O3L:YI4B:NYXU:T4VR:USH6:CXXN:SELU:CSCC:FVPE", - "kty": "EC", - "x": "cR1zye_3354mdbD7Dn-mtXNXvtPtmLlUVDa5vH6Lp74", - "y": "rldUXSllLit6_2BW6AV8aqkwWJXHoYPG9OwkIBouwxQ" - }, - "alg": "ES256" - }, - "signature": "DYB2iB-XKIisqp5Q0OXFOBIOlBOuRV7pnZuKy0cxVB2Qj1VFRhWX4Tq336y0VMWbF6ma1he5A1E_Vk4jazrJ9g", - "protected": "eyJmb3JtYXRMZW5ndGgiOjIxMzcsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAyMC0xMS0yNFQyMjowMTo1MVoifQ" + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 2810825, + "digest": "sha256:596ba82af5aaa3e2fd9d6f955b8b94f0744a2b60710e3c243ba3e4a467f051d1" } ] }
\ No newline at end of file diff --git a/spec/fixtures/lib/gitlab/import_export/complex/project.json b/spec/fixtures/lib/gitlab/import_export/complex/project.json index 637e01bf123..9b8bc9d304e 100644 --- a/spec/fixtures/lib/gitlab/import_export/complex/project.json +++ b/spec/fixtures/lib/gitlab/import_export/complex/project.json @@ -7032,7 +7032,8 @@ "created_at": "2016-08-30T07:32:52.490Z", "updated_at": "2016-08-30T07:32:52.490Z" } - ] + ], + "allow_force_push":false } ], "protected_environments": [ diff --git a/spec/fixtures/security_reports/master/gl-sast-report.json b/spec/fixtures/security_reports/master/gl-sast-report.json index 98bb15e349f..ab610945508 100644 --- a/spec/fixtures/security_reports/master/gl-sast-report.json +++ b/spec/fixtures/security_reports/master/gl-sast-report.json @@ -28,7 +28,21 @@ "file": "python/hardcoded/hardcoded-tmp.py", "line": 1, "url": "https://docs.openstack.org/bandit/latest/plugins/b108_hardcoded_tmp_directory.html", - "tool": "bandit" + "tool": "bandit", + "tracking": { + "type": "source", + "items": [ + { + "file": "python/hardcoded/hardcoded-tmp.py", + "start_line": 1, + "end_line": 1, + "fingerprints": [ + { "algorithm": "hash", "value": "HASHVALUE" }, + { "algorithm": "scope_offset", "value": "python/hardcoded/hardcoded-tmp.py:ClassA:method_b:2" } + ] + } + ] + } }, { "category": "sast", diff --git a/spec/frontend/.eslintrc.yml b/spec/frontend/.eslintrc.yml index d0e585e844a..145e6c8961a 100644 --- a/spec/frontend/.eslintrc.yml +++ b/spec/frontend/.eslintrc.yml @@ -14,7 +14,6 @@ settings: globals: getJSONFixture: false loadFixtures: false - preloadFixtures: false setFixtures: false rules: jest/expect-expect: diff --git a/spec/frontend/__helpers__/fake_date/fixtures.js b/spec/frontend/__helpers__/fake_date/fixtures.js new file mode 100644 index 00000000000..fcf9d4a9c64 --- /dev/null +++ b/spec/frontend/__helpers__/fake_date/fixtures.js @@ -0,0 +1,4 @@ +import { useFakeDate } from './jest'; + +// Also see spec/support/helpers/javascript_fixtures_helpers.rb +export const useFixturesFakeDate = () => useFakeDate(2015, 6, 3, 10); diff --git a/spec/frontend/__helpers__/fake_date/index.js b/spec/frontend/__helpers__/fake_date/index.js index 3d1b124ce79..9d00349bd26 100644 --- a/spec/frontend/__helpers__/fake_date/index.js +++ b/spec/frontend/__helpers__/fake_date/index.js @@ -1,2 +1,3 @@ export * from './fake_date'; export * from './jest'; +export * from './fixtures'; diff --git a/spec/frontend/__helpers__/vue_test_utils_helper.js b/spec/frontend/__helpers__/vue_test_utils_helper.js index ffccfb249c2..d6132ef84ac 100644 --- a/spec/frontend/__helpers__/vue_test_utils_helper.js +++ b/spec/frontend/__helpers__/vue_test_utils_helper.js @@ -45,9 +45,16 @@ export const extendedWrapper = (wrapper) => { return wrapper; } - return Object.defineProperty(wrapper, 'findByTestId', { - value(id) { - return this.find(`[data-testid="${id}"]`); + return Object.defineProperties(wrapper, { + findByTestId: { + value(id) { + return this.find(`[data-testid="${id}"]`); + }, + }, + findAllByTestId: { + value(id) { + return this.findAll(`[data-testid="${id}"]`); + }, }, }); }; diff --git a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js index 31c4ccd5dbb..d4f8e36c169 100644 --- a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js +++ b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js @@ -88,5 +88,22 @@ describe('Vue test utils helpers', () => { expect(mockComponent.findByTestId(testId).exists()).toBe(true); }); }); + + describe('findAllByTestId', () => { + const testId = 'a-component'; + let mockComponent; + + beforeEach(() => { + mockComponent = extendedWrapper( + shallowMount({ + template: `<div><div data-testid="${testId}"></div><div data-testid="${testId}"></div></div>`, + }), + ); + }); + + it('should find all components by test id', () => { + expect(mockComponent.findAllByTestId(testId)).toHaveLength(2); + }); + }); }); }); diff --git a/spec/frontend/__mocks__/@gitlab/ui.js b/spec/frontend/__mocks__/@gitlab/ui.js index ecd67247362..4c491a87fcb 100644 --- a/spec/frontend/__mocks__/@gitlab/ui.js +++ b/spec/frontend/__mocks__/@gitlab/ui.js @@ -39,7 +39,10 @@ jest.mock('@gitlab/ui/dist/components/base/popover/popover.js', () => ({ default: () => [], }, ...Object.fromEntries( - ['target', 'triggers', 'placement', 'boundary', 'container'].map((prop) => [prop, {}]), + ['title', 'target', 'triggers', 'placement', 'boundary', 'container'].map((prop) => [ + prop, + {}, + ]), ), }, render(h) { diff --git a/spec/frontend/access_tokens/components/projects_field_spec.js b/spec/frontend/access_tokens/components/projects_field_spec.js new file mode 100644 index 00000000000..a9e0799d114 --- /dev/null +++ b/spec/frontend/access_tokens/components/projects_field_spec.js @@ -0,0 +1,131 @@ +import { within, fireEvent } from '@testing-library/dom'; +import { mount } from '@vue/test-utils'; +import ProjectsField from '~/access_tokens/components/projects_field.vue'; +import ProjectsTokenSelector from '~/access_tokens/components/projects_token_selector.vue'; + +describe('ProjectsField', () => { + let wrapper; + + const createComponent = ({ inputAttrsValue = '' } = {}) => { + wrapper = mount(ProjectsField, { + propsData: { + inputAttrs: { + id: 'projects', + name: 'projects', + value: inputAttrsValue, + }, + }, + }); + }; + + const queryByLabelText = (text) => within(wrapper.element).queryByLabelText(text); + const queryByText = (text) => within(wrapper.element).queryByText(text); + const findAllProjectsRadio = () => queryByLabelText('All projects'); + const findSelectedProjectsRadio = () => queryByLabelText('Selected projects'); + const findProjectsTokenSelector = () => wrapper.findComponent(ProjectsTokenSelector); + const findHiddenInput = () => wrapper.find('input[type="hidden"]'); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders label and sub-label', () => { + createComponent(); + + expect(queryByText('Projects')).not.toBe(null); + expect(queryByText('Set access permissions for this token.')).not.toBe(null); + }); + + describe('when `inputAttrs.value` is empty', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders "All projects" radio as checked', () => { + expect(findAllProjectsRadio().checked).toBe(true); + }); + + it('renders "Selected projects" radio as unchecked', () => { + expect(findSelectedProjectsRadio().checked).toBe(false); + }); + + it('sets `projects-token-selector` `initialProjectIds` prop to an empty array', () => { + expect(findProjectsTokenSelector().props('initialProjectIds')).toEqual([]); + }); + }); + + describe('when `inputAttrs.value` is a comma separated list of project IDs', () => { + beforeEach(() => { + createComponent({ inputAttrsValue: '1,2' }); + }); + + it('renders "All projects" radio as unchecked', () => { + expect(findAllProjectsRadio().checked).toBe(false); + }); + + it('renders "Selected projects" radio as checked', () => { + expect(findSelectedProjectsRadio().checked).toBe(true); + }); + + it('sets `projects-token-selector` `initialProjectIds` prop to an array of project IDs', () => { + expect(findProjectsTokenSelector().props('initialProjectIds')).toEqual(['1', '2']); + }); + }); + + it('renders `projects-token-selector` component', () => { + createComponent(); + + expect(findProjectsTokenSelector().exists()).toBe(true); + }); + + it('renders hidden input with correct `name` and `id` attributes', () => { + createComponent(); + + expect(findHiddenInput().attributes()).toEqual( + expect.objectContaining({ + id: 'projects', + name: 'projects', + }), + ); + }); + + describe('when `projects-token-selector` is focused', () => { + beforeEach(() => { + createComponent(); + + findProjectsTokenSelector().vm.$emit('focus'); + }); + + it('auto selects the "Selected projects" radio', () => { + expect(findSelectedProjectsRadio().checked).toBe(true); + }); + + describe('when `projects-token-selector` is changed', () => { + beforeEach(() => { + findProjectsTokenSelector().vm.$emit('input', [ + { + id: 1, + }, + { + id: 2, + }, + ]); + }); + + it('updates the hidden input value to a comma separated list of project IDs', () => { + expect(findHiddenInput().attributes('value')).toBe('1,2'); + }); + + describe('when radio is changed back to "All projects"', () => { + beforeEach(() => { + fireEvent.click(findAllProjectsRadio()); + }); + + it('removes the hidden input value', () => { + expect(findHiddenInput().attributes('value')).toBe(''); + }); + }); + }); + }); +}); diff --git a/spec/frontend/access_tokens/components/projects_token_selector_spec.js b/spec/frontend/access_tokens/components/projects_token_selector_spec.js new file mode 100644 index 00000000000..09f52fe9a5f --- /dev/null +++ b/spec/frontend/access_tokens/components/projects_token_selector_spec.js @@ -0,0 +1,269 @@ +import { + GlAvatar, + GlAvatarLabeled, + GlIntersectionObserver, + GlToken, + GlTokenSelector, + GlLoadingIcon, +} from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import produce from 'immer'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; + +import { getJSONFixture } from 'helpers/fixtures'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ProjectsTokenSelector from '~/access_tokens/components/projects_token_selector.vue'; +import getProjectsQuery from '~/access_tokens/graphql/queries/get_projects.query.graphql'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +describe('ProjectsTokenSelector', () => { + const getProjectsQueryResponse = getJSONFixture( + 'graphql/projects/access_tokens/get_projects.query.graphql.json', + ); + const getProjectsQueryResponsePage2 = produce( + getProjectsQueryResponse, + (getProjectsQueryResponseDraft) => { + /* eslint-disable no-param-reassign */ + getProjectsQueryResponseDraft.data.projects.pageInfo.hasNextPage = false; + getProjectsQueryResponseDraft.data.projects.pageInfo.endCursor = null; + getProjectsQueryResponseDraft.data.projects.nodes.splice(1, 1); + getProjectsQueryResponseDraft.data.projects.nodes[0].id = 'gid://gitlab/Project/100'; + /* eslint-enable no-param-reassign */ + }, + ); + + const runDebounce = () => jest.runAllTimers(); + + const { pageInfo, nodes: projects } = getProjectsQueryResponse.data.projects; + const project1 = projects[0]; + const project2 = projects[1]; + + let wrapper; + + let resolveGetProjectsQuery; + let resolveGetInitialProjectsQuery; + const getProjectsQueryRequestHandler = jest.fn( + ({ ids }) => + new Promise((resolve) => { + if (ids) { + resolveGetInitialProjectsQuery = resolve; + } else { + resolveGetProjectsQuery = resolve; + } + }), + ); + + const createComponent = ({ + propsData = {}, + apolloProvider = createMockApollo([[getProjectsQuery, getProjectsQueryRequestHandler]]), + resolveQueries = true, + } = {}) => { + Vue.use(VueApollo); + + wrapper = extendedWrapper( + mount(ProjectsTokenSelector, { + apolloProvider, + propsData: { + selectedProjects: [], + initialProjectIds: [], + ...propsData, + }, + stubs: ['gl-intersection-observer'], + }), + ); + + runDebounce(); + + if (resolveQueries) { + resolveGetProjectsQuery(getProjectsQueryResponse); + + return waitForPromises(); + } + + return Promise.resolve(); + }; + + const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); + const findTokenSelectorInput = () => findTokenSelector().find('input[type="text"]'); + const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); + + it('renders dropdown items with project avatars', async () => { + await createComponent(); + + wrapper.findAllComponents(GlAvatarLabeled).wrappers.forEach((avatarLabeledWrapper, index) => { + const project = projects[index]; + + expect(avatarLabeledWrapper.attributes()).toEqual( + expect.objectContaining({ + 'entity-id': `${getIdFromGraphQLId(project.id)}`, + 'entity-name': project.name, + ...(project.avatarUrl && { src: project.avatarUrl }), + }), + ); + + expect(avatarLabeledWrapper.props()).toEqual( + expect.objectContaining({ + label: project.name, + subLabel: project.nameWithNamespace, + }), + ); + }); + }); + + it('renders tokens with project avatars', () => { + createComponent({ + propsData: { + selectedProjects: [{ ...project2, id: getIdFromGraphQLId(project2.id) }], + }, + }); + + const token = wrapper.findComponent(GlToken); + const avatar = token.findComponent(GlAvatar); + + expect(token.text()).toContain(project2.nameWithNamespace); + expect(avatar.attributes('src')).toBe(project2.avatarUrl); + expect(avatar.props()).toEqual( + expect.objectContaining({ + entityId: getIdFromGraphQLId(project2.id), + entityName: project2.name, + }), + ); + }); + + describe('when `enter` key is pressed', () => { + it('calls `preventDefault` so form is not submitted when user selects a project from the dropdown', () => { + createComponent(); + + const event = { + preventDefault: jest.fn(), + }; + + findTokenSelectorInput().trigger('keydown.enter', event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); + + describe('when text input is typed in', () => { + const searchTerm = 'foo bar'; + + beforeEach(async () => { + await createComponent(); + + await findTokenSelectorInput().setValue(searchTerm); + runDebounce(); + }); + + it('makes GraphQL request with `search` variable set', async () => { + expect(getProjectsQueryRequestHandler).toHaveBeenLastCalledWith({ + search: searchTerm, + after: null, + first: 20, + ids: null, + }); + }); + + it('sets loading state while waiting for GraphQL request to resolve', async () => { + expect(findTokenSelector().props('loading')).toBe(true); + + resolveGetProjectsQuery(getProjectsQueryResponse); + await waitForPromises(); + + expect(findTokenSelector().props('loading')).toBe(false); + }); + }); + + describe('when there is a next page of projects and user scrolls to the bottom of the dropdown', () => { + beforeEach(async () => { + await createComponent(); + + findIntersectionObserver().vm.$emit('appear'); + }); + + it('makes GraphQL request with `after` variable set', async () => { + expect(getProjectsQueryRequestHandler).toHaveBeenLastCalledWith({ + after: pageInfo.endCursor, + first: 20, + search: '', + ids: null, + }); + }); + + it('displays loading icon while waiting for GraphQL request to resolve', async () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + + resolveGetProjectsQuery(getProjectsQueryResponsePage2); + await waitForPromises(); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); + }); + }); + + describe('when there is not a next page of projects', () => { + it('does not render `GlIntersectionObserver`', async () => { + createComponent({ resolveQueries: false }); + + resolveGetProjectsQuery(getProjectsQueryResponsePage2); + await waitForPromises(); + + expect(findIntersectionObserver().exists()).toBe(false); + }); + }); + + describe('when `GlTokenSelector` emits `input` event', () => { + it('emits `input` event used by `v-model`', () => { + findTokenSelector().vm.$emit('input', project1); + + expect(wrapper.emitted('input')[0]).toEqual([project1]); + }); + }); + + describe('when `GlTokenSelector` emits `focus` event', () => { + it('emits `focus` event', () => { + const event = { fakeEvent: 'foo' }; + findTokenSelector().vm.$emit('focus', event); + + expect(wrapper.emitted('focus')[0]).toEqual([event]); + }); + }); + + describe('when `initialProjectIds` is an empty array', () => { + it('does not request initial projects', async () => { + await createComponent(); + + expect(getProjectsQueryRequestHandler).toHaveBeenCalledTimes(1); + expect(getProjectsQueryRequestHandler).toHaveBeenCalledWith( + expect.objectContaining({ + ids: null, + }), + ); + }); + }); + + describe('when `initialProjectIds` is an array of project IDs', () => { + it('requests those projects and emits `input` event with result', async () => { + await createComponent({ + propsData: { + initialProjectIds: [getIdFromGraphQLId(project1.id), getIdFromGraphQLId(project2.id)], + }, + }); + + resolveGetInitialProjectsQuery(getProjectsQueryResponse); + await waitForPromises(); + + expect(getProjectsQueryRequestHandler).toHaveBeenCalledWith({ + after: '', + first: null, + search: '', + ids: [project1.id, project2.id], + }); + expect(wrapper.emitted('input')[0][0]).toEqual([ + { ...project1, id: getIdFromGraphQLId(project1.id) }, + { ...project2, id: getIdFromGraphQLId(project2.id) }, + ]); + }); + }); +}); diff --git a/spec/frontend/access_tokens/index_spec.js b/spec/frontend/access_tokens/index_spec.js new file mode 100644 index 00000000000..e3f17e21739 --- /dev/null +++ b/spec/frontend/access_tokens/index_spec.js @@ -0,0 +1,74 @@ +import { createWrapper } from '@vue/test-utils'; +import Vue from 'vue'; + +import { initExpiresAtField, initProjectsField } from '~/access_tokens'; +import * as ExpiresAtField from '~/access_tokens/components/expires_at_field.vue'; +import * as ProjectsField from '~/access_tokens/components/projects_field.vue'; + +describe('access tokens', () => { + const FakeComponent = Vue.component('FakeComponent', { + props: { + inputAttrs: { + type: Object, + required: true, + }, + }, + render: () => null, + }); + + beforeEach(() => { + window.gon = { features: { personalAccessTokensScopedToProjects: true } }; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe.each` + initFunction | mountSelector | expectedComponent + ${initExpiresAtField} | ${'js-access-tokens-expires-at'} | ${ExpiresAtField} + ${initProjectsField} | ${'js-access-tokens-projects'} | ${ProjectsField} + `('$initFunction', ({ initFunction, mountSelector, expectedComponent }) => { + describe('when mount element exists', () => { + beforeEach(() => { + const mountEl = document.createElement('div'); + mountEl.classList.add(mountSelector); + + const input = document.createElement('input'); + input.setAttribute('name', 'foo-bar'); + input.setAttribute('id', 'foo-bar'); + input.setAttribute('placeholder', 'Foo bar'); + input.setAttribute('value', '1,2'); + + mountEl.appendChild(input); + + document.body.appendChild(mountEl); + + // Mock component so we don't have to deal with mocking Apollo + // eslint-disable-next-line no-param-reassign + expectedComponent.default = FakeComponent; + }); + + it('mounts component and sets `inputAttrs` prop', async () => { + const vueInstance = await initFunction(); + + const wrapper = createWrapper(vueInstance); + const component = wrapper.findComponent(FakeComponent); + + expect(component.exists()).toBe(true); + expect(component.props('inputAttrs')).toEqual({ + name: 'foo-bar', + id: 'foo-bar', + value: '1,2', + placeholder: 'Foo bar', + }); + }); + }); + + describe('when mount element does not exist', () => { + it('returns `null`', () => { + expect(initFunction()).toBe(null); + }); + }); + }); +}); diff --git a/spec/frontend/admin/users/tabs_spec.js b/spec/frontend/admin/users/tabs_spec.js new file mode 100644 index 00000000000..39ba8618486 --- /dev/null +++ b/spec/frontend/admin/users/tabs_spec.js @@ -0,0 +1,37 @@ +import initTabs from '~/admin/users/tabs'; +import Api from '~/api'; + +jest.mock('~/api.js'); +jest.mock('~/lib/utils/common_utils'); + +describe('tabs', () => { + beforeEach(() => { + setFixtures(` + <div> + <div class="js-users-tab-item"> + <a href="#users" data-testid='users-tab'>Users</a> + </div> + <div class="js-users-tab-item"> + <a href="#cohorts" data-testid='cohorts-tab'>Cohorts</a> + </div> + </div`); + + initTabs(); + }); + + afterEach(() => {}); + + describe('tracking', () => { + it('tracks event when cohorts tab is clicked', () => { + document.querySelector('[data-testid="cohorts-tab"]').click(); + + expect(Api.trackRedisHllUserEvent).toHaveBeenCalledWith('i_analytics_cohorts'); + }); + + it('does not track an event when users tab is clicked', () => { + document.querySelector('[data-testid="users-tab"]').click(); + + expect(Api.trackRedisHllUserEvent).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/alert_management/components/alert_management_table_spec.js b/spec/frontend/alert_management/components/alert_management_table_spec.js index cea665aa50d..dece3dfbe5f 100644 --- a/spec/frontend/alert_management/components/alert_management_table_spec.js +++ b/spec/frontend/alert_management/components/alert_management_table_spec.js @@ -2,6 +2,8 @@ import { GlTable, GlAlert, GlLoadingIcon, GlDropdown, GlIcon, GlAvatar } from '@ import { mount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import mockAlerts from 'jest/vue_shared/alert_details/mocks/alerts.json'; import AlertManagementTable from '~/alert_management/components/alert_management_table.vue'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -18,19 +20,18 @@ describe('AlertManagementTable', () => { let wrapper; let mock; - const findAlertsTable = () => wrapper.find(GlTable); + const findAlertsTable = () => wrapper.findComponent(GlTable); const findAlerts = () => wrapper.findAll('table tbody tr'); - const findAlert = () => wrapper.find(GlAlert); - const findLoader = () => wrapper.find(GlLoadingIcon); - const findStatusDropdown = () => wrapper.find(GlDropdown); - const findDateFields = () => wrapper.findAll(TimeAgo); - const findSearch = () => wrapper.find(FilteredSearchBar); - const findSeverityColumnHeader = () => - wrapper.find('[data-testid="alert-management-severity-sort"]'); - const findFirstIDField = () => wrapper.findAll('[data-testid="idField"]').at(0); - const findAssignees = () => wrapper.findAll('[data-testid="assigneesField"]'); - const findSeverityFields = () => wrapper.findAll('[data-testid="severityField"]'); - const findIssueFields = () => wrapper.findAll('[data-testid="issueField"]'); + const findAlert = () => wrapper.findComponent(GlAlert); + const findLoader = () => wrapper.findComponent(GlLoadingIcon); + const findStatusDropdown = () => wrapper.findComponent(GlDropdown); + const findDateFields = () => wrapper.findAllComponents(TimeAgo); + const findSearch = () => wrapper.findComponent(FilteredSearchBar); + const findSeverityColumnHeader = () => wrapper.findByTestId('alert-management-severity-sort'); + const findFirstIDField = () => wrapper.findAllByTestId('idField').at(0); + const findAssignees = () => wrapper.findAllByTestId('assigneesField'); + const findSeverityFields = () => wrapper.findAllByTestId('severityField'); + const findIssueFields = () => wrapper.findAllByTestId('issueField'); const alertsCount = { open: 24, triggered: 20, @@ -40,29 +41,34 @@ describe('AlertManagementTable', () => { }; function mountComponent({ provide = {}, data = {}, loading = false, stubs = {} } = {}) { - wrapper = mount(AlertManagementTable, { - provide: { - ...defaultProvideValues, - alertManagementEnabled: true, - userCanEnableAlertManagement: true, - ...provide, - }, - data() { - return data; - }, - mocks: { - $apollo: { - mutate: jest.fn(), - query: jest.fn(), - queries: { - alerts: { - loading, + wrapper = extendedWrapper( + mount(AlertManagementTable, { + provide: { + ...defaultProvideValues, + alertManagementEnabled: true, + userCanEnableAlertManagement: true, + ...provide, + }, + data() { + return data; + }, + mocks: { + $apollo: { + mutate: jest.fn(), + query: jest.fn(), + queries: { + alerts: { + loading, + }, }, }, }, - }, - stubs, - }); + stubs, + directives: { + GlTooltip: createMockDirective(), + }, + }), + ); } beforeEach(() => { @@ -72,7 +78,6 @@ describe('AlertManagementTable', () => { afterEach(() => { if (wrapper) { wrapper.destroy(); - wrapper = null; } mock.restore(); }); @@ -241,9 +246,14 @@ describe('AlertManagementTable', () => { expect(findIssueFields().at(0).text()).toBe('None'); }); - it('renders a link when one exists', () => { - expect(findIssueFields().at(1).text()).toBe('#1'); - expect(findIssueFields().at(1).attributes('href')).toBe('/gitlab-org/gitlab/-/issues/1'); + it('renders a link when one exists with the issue state and title tooltip', () => { + const issueField = findIssueFields().at(1); + const tooltip = getBinding(issueField.element, 'gl-tooltip'); + + expect(issueField.text()).toBe(`#1 (closed)`); + expect(issueField.attributes('href')).toBe('/gitlab-org/gitlab/-/issues/incident/1'); + expect(issueField.attributes('title')).toBe('My test issue'); + expect(tooltip).not.toBe(undefined); }); }); diff --git a/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap index eb2b82a0211..1f8429af7dd 100644 --- a/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap +++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap @@ -4,125 +4,151 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`] <form class="gl-mt-6" > - <h5 - class="gl-font-lg gl-my-5" - > - Add new integrations - </h5> - <div - class="form-group gl-form-group" - id="integration-type" - role="group" + class="tabs gl-tabs" + id="__BVID__6" > - <label - class="d-block col-form-label" - for="integration-type" - id="integration-type__BV_label_" - > - 1. Select integration type - </label> + <!----> <div - class="bv-no-focus-ring" + class="" > - <select - class="gl-form-select mw-100 custom-select" - id="__BVID__8" + <ul + class="nav gl-tabs-nav" + id="__BVID__6__BV_tab_controls_" + role="tablist" > - <option - value="" + <!----> + <li + class="nav-item" + role="presentation" > - Select integration type - </option> - <option - value="HTTP" + <a + aria-controls="__BVID__8" + aria-posinset="1" + aria-selected="true" + aria-setsize="3" + class="nav-link active gl-tab-nav-item gl-tab-nav-item-active gl-tab-nav-item-active-indigo" + href="#" + id="__BVID__8___BV_tab_button__" + role="tab" + target="_self" + > + Configure details + </a> + </li> + <li + class="nav-item" + role="presentation" > - HTTP Endpoint - </option> - <option - value="PROMETHEUS" + <a + aria-controls="__BVID__19" + aria-disabled="true" + aria-posinset="2" + aria-selected="false" + aria-setsize="3" + class="nav-link disabled disabled gl-tab-nav-item" + href="#" + id="__BVID__19___BV_tab_button__" + role="tab" + tabindex="-1" + target="_self" + > + View credentials + </a> + </li> + <li + class="nav-item" + role="presentation" > - External Prometheus - </option> - </select> - - <!----> - <!----> - <!----> - <!----> + <a + aria-controls="__BVID__41" + aria-disabled="true" + aria-posinset="3" + aria-selected="false" + aria-setsize="3" + class="nav-link disabled disabled gl-tab-nav-item" + href="#" + id="__BVID__41___BV_tab_button__" + role="tab" + tabindex="-1" + target="_self" + > + Send test alert + </a> + </li> + <!----> + </ul> </div> - </div> - - <transition-stub - class="gl-mt-3" - css="true" - enteractiveclass="collapsing" - enterclass="" - entertoclass="collapse show" - leaveactiveclass="collapsing" - leaveclass="collapse show" - leavetoclass="collapse" - > <div - class="collapse" - id="__BVID__10" - style="display: none;" + class="tab-content gl-tab-content" + id="__BVID__6__BV_tab_container_" > - <div> + <transition-stub + css="true" + enteractiveclass="" + enterclass="" + entertoclass="show" + leaveactiveclass="" + leaveclass="show" + leavetoclass="" + mode="out-in" + name="" + > <div - class="form-group gl-form-group" - id="name-integration" - role="group" + aria-hidden="false" + aria-labelledby="__BVID__8___BV_tab_button__" + class="tab-pane active" + id="__BVID__8" + role="tabpanel" + style="" > - <label - class="d-block col-form-label" - for="name-integration" - id="name-integration__BV_label_" - > - 2. Name integration - </label> <div - class="bv-no-focus-ring" + class="form-group gl-form-group" + id="integration-type" + role="group" > - <input - class="gl-form-input form-control" - id="__BVID__15" - placeholder="Enter integration name" - type="text" - /> - <!----> - <!----> - <!----> + <label + class="d-block col-form-label" + for="integration-type" + id="integration-type__BV_label_" + > + 1.Select integration type + </label> + <div + class="bv-no-focus-ring" + > + <select + class="gl-form-select gl-max-w-full custom-select" + id="__BVID__13" + > + <option + value="" + > + Select integration type + </option> + <option + value="HTTP" + > + HTTP Endpoint + </option> + <option + value="PROMETHEUS" + > + External Prometheus + </option> + </select> + + <!----> + <!----> + <!----> + <!----> + </div> </div> - </div> - - <div - class="form-group gl-form-group" - id="integration-webhook" - role="group" - > - <label - class="d-block col-form-label" - for="integration-webhook" - id="integration-webhook__BV_label_" - > - 3. Set up webhook - </label> + <div - class="bv-no-focus-ring" + class="gl-mt-3" > - <span> - Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the - <a - class="gl-link gl-display-inline-block" - href="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html" - rel="noopener noreferrer" - target="_blank" - > - GitLab documentation - </a> - to learn more about configuring your endpoint. - </span> + <!----> <label class="gl-display-flex gl-flex-direction-column gl-mb-0 gl-w-max-content gl-my-4 gl-font-weight-normal" @@ -166,241 +192,333 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`] <!----> - <div - class="gl-my-4" + <!----> + </div> + + <div + class="gl-display-flex gl-justify-content-start gl-py-3" + > + <button + class="btn js-no-auto-disable btn-confirm btn-md gl-button" + data-testid="integration-form-submit" + type="submit" > + <!----> + + <!----> + <span - class="gl-font-weight-bold" + class="gl-button-text" > - Webhook URL - + Save integration + </span> + </button> + + <button + class="btn gl-ml-3 js-no-auto-disable btn-default btn-md gl-button" + type="reset" + > + <!----> + <!----> + + <span + class="gl-button-text" + > + Cancel and close + </span> + </button> + </div> + </div> + </transition-stub> + + <transition-stub + css="true" + enteractiveclass="" + enterclass="" + entertoclass="show" + leaveactiveclass="" + leaveclass="show" + leavetoclass="" + mode="out-in" + name="" + > + <div + aria-hidden="true" + aria-labelledby="__BVID__19___BV_tab_button__" + class="tab-pane disabled" + id="__BVID__19" + role="tabpanel" + style="display: none;" + > + <span> + Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the + <a + class="gl-link gl-display-inline-block" + href="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html" + rel="noopener noreferrer" + target="_blank" + > + GitLab documentation + </a> + to learn more about configuring your endpoint. + </span> + + <fieldset + class="form-group gl-form-group" + id="integration-webhook" + > + <!----> + <div + class="bv-no-focus-ring" + role="group" + tabindex="-1" + > <div - id="url" - readonly="readonly" + class="gl-my-4" > + <span + class="gl-font-weight-bold" + > + + Webhook URL + + </span> + <div - class="input-group" - role="group" + id="url" + readonly="readonly" > - <!----> - <!----> - - <input - class="gl-form-input form-control" - id="url" - readonly="readonly" - type="text" - /> - <div - class="input-group-append" + class="input-group" + role="group" > - <button - aria-label="Copy this value" - class="btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon" - data-clipboard-text="" - title="Copy" - type="button" + <!----> + <!----> + + <input + class="gl-form-input form-control" + id="url" + readonly="readonly" + type="text" + /> + + <div + class="input-group-append" > - <!----> - - <svg - aria-hidden="true" - class="gl-button-icon gl-icon s16" - data-testid="copy-to-clipboard-icon" + <button + aria-label="Copy this value" + class="btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon" + data-clipboard-text="" + title="Copy" + type="button" > - <use - href="#copy-to-clipboard" - /> - </svg> - - <!----> - </button> + <!----> + + <svg + aria-hidden="true" + class="gl-button-icon gl-icon s16" + data-testid="copy-to-clipboard-icon" + > + <use + href="#copy-to-clipboard" + /> + </svg> + + <!----> + </button> + </div> + <!----> </div> - <!----> </div> </div> - </div> - - <div - class="gl-my-4" - > - <span - class="gl-font-weight-bold" - > - - Authorization key - - </span> <div - class="gl-mb-3" - id="authorization-key" - readonly="readonly" + class="gl-my-4" > + <span + class="gl-font-weight-bold" + > + + Authorization key + + </span> + <div - class="input-group" - role="group" + class="gl-mb-3" + id="authorization-key" + readonly="readonly" > - <!----> - <!----> - - <input - class="gl-form-input form-control" - id="authorization-key" - readonly="readonly" - type="text" - /> - <div - class="input-group-append" + class="input-group" + role="group" > - <button - aria-label="Copy this value" - class="btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon" - data-clipboard-text="" - title="Copy" - type="button" + <!----> + <!----> + + <input + class="gl-form-input form-control" + id="authorization-key" + readonly="readonly" + type="text" + /> + + <div + class="input-group-append" > - <!----> - - <svg - aria-hidden="true" - class="gl-button-icon gl-icon s16" - data-testid="copy-to-clipboard-icon" + <button + aria-label="Copy this value" + class="btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon" + data-clipboard-text="" + title="Copy" + type="button" > - <use - href="#copy-to-clipboard" - /> - </svg> - - <!----> - </button> + <!----> + + <svg + aria-hidden="true" + class="gl-button-icon gl-icon s16" + data-testid="copy-to-clipboard-icon" + > + <use + href="#copy-to-clipboard" + /> + </svg> + + <!----> + </button> + </div> + <!----> </div> - <!----> </div> </div> - - <button - class="btn btn-default btn-md disabled gl-button" - disabled="disabled" - type="button" - > - <!----> - - <!----> - - <span - class="gl-button-text" - > - - Reset Key - - </span> - </button> - + <!----> + <!----> <!----> </div> - <!----> - <!----> - <!----> - </div> - </div> - - <div - class="form-group gl-form-group" - id="test-integration" - role="group" - > - <label - class="d-block col-form-label" - for="test-integration" - id="test-integration__BV_label_" - > - 4. Sample alert payload (optional) - </label> - <div - class="bv-no-focus-ring" + </fieldset> + + <button + class="btn btn-danger btn-md disabled gl-button" + disabled="disabled" + type="button" > - <span> - Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional). - </span> + <!----> - <textarea - class="gl-form-input gl-form-textarea gl-my-3 form-control is-valid" - disabled="disabled" - id="test-payload" - placeholder="{ \\"events\\": [{ \\"application\\": \\"Name of application\\" }] }" - style="resize: none; overflow-y: scroll;" - wrap="soft" - /> <!----> + + <span + class="gl-button-text" + > + + Reset Key + + </span> + </button> + + <button + class="btn gl-ml-3 js-no-auto-disable btn-default btn-md gl-button" + type="reset" + > <!----> + <!----> - </div> + + <span + class="gl-button-text" + > + Cancel and close + </span> + </button> + + <!----> </div> - - <!----> - - <!----> - </div> + </transition-stub> - <div - class="gl-display-flex gl-justify-content-start gl-py-3" + <transition-stub + css="true" + enteractiveclass="" + enterclass="" + entertoclass="show" + leaveactiveclass="" + leaveclass="show" + leavetoclass="" + mode="out-in" + name="" > - <button - class="btn js-no-auto-disable btn-success btn-md gl-button" - data-testid="integration-form-submit" - type="submit" + <div + aria-hidden="true" + aria-labelledby="__BVID__41___BV_tab_button__" + class="tab-pane disabled" + id="__BVID__41" + role="tabpanel" + style="display: none;" > - <!----> - - <!----> - - <span - class="gl-button-text" + <fieldset + class="form-group gl-form-group" + id="test-integration" > - Save integration - - </span> - </button> - - <button - class="btn gl-mx-3 js-no-auto-disable btn-success btn-md disabled gl-button btn-success-secondary" - data-testid="integration-test-and-submit" - disabled="disabled" - type="button" - > - <!----> + <!----> + <div + class="bv-no-focus-ring" + role="group" + tabindex="-1" + > + <span> + Provide an example payload from the monitoring tool you intend to integrate with. This will allow you to send an alert to an active GitLab alerting point. + </span> + + <textarea + class="gl-form-input gl-form-textarea gl-my-3 form-control is-valid" + id="test-payload" + placeholder="{ \\"events\\": [{ \\"application\\": \\"Name of application\\" }] }" + style="resize: none; overflow-y: scroll;" + wrap="soft" + /> + <!----> + <!----> + <!----> + </div> + </fieldset> - <!----> - - <span - class="gl-button-text" + <button + class="btn js-no-auto-disable btn-confirm btn-md gl-button" + data-testid="send-test-alert" + type="button" > - Save and test payload - </span> - </button> - - <button - class="btn js-no-auto-disable btn-default btn-md gl-button" - type="reset" - > - <!----> + <!----> + + <!----> + + <span + class="gl-button-text" + > + + Send + + </span> + </button> - <!----> - - <span - class="gl-button-text" + <button + class="btn gl-ml-3 js-no-auto-disable btn-default btn-md gl-button" + type="reset" > - Cancel - </span> - </button> - </div> + <!----> + + <!----> + + <span + class="gl-button-text" + > + Cancel and close + </span> + </button> + </div> + </transition-stub> + <!----> </div> - </transition-stub> + </div> </form> `; diff --git a/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js index 7e1d1acb62c..dba9c8be669 100644 --- a/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js +++ b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js @@ -1,10 +1,10 @@ import { GlIcon, GlFormInput, GlDropdown, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import AlertMappingBuilder, { i18n } from '~/alerts_settings/components/alert_mapping_builder.vue'; -import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json'; import * as transformationUtils from '~/alerts_settings/utils/mapping_transformations'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; -import alertFields from '../mocks/alertFields.json'; +import alertFields from '../mocks/alert_fields.json'; +import parsedMapping from '../mocks/parsed_mapping.json'; describe('AlertMappingBuilder', () => { let wrapper; @@ -12,8 +12,8 @@ describe('AlertMappingBuilder', () => { function mountComponent() { wrapper = shallowMount(AlertMappingBuilder, { propsData: { - parsedPayload: parsedMapping.samplePayload.payloadAlerFields.nodes, - savedMapping: parsedMapping.storedMapping.nodes, + parsedPayload: parsedMapping.payloadAlerFields, + savedMapping: parsedMapping.payloadAttributeMappings, alertFields, }, }); @@ -33,6 +33,15 @@ describe('AlertMappingBuilder', () => { const findColumnInRow = (row, column) => wrapper.findAll('.gl-display-table-row').at(row).findAll('.gl-display-table-cell ').at(column); + const getDropdownContent = (dropdown, types) => { + const searchBox = dropdown.findComponent(GlSearchBoxByType); + const dropdownItems = dropdown.findAllComponents(GlDropdownItem); + const mappingOptions = parsedMapping.payloadAlerFields.filter(({ type }) => + types.includes(type), + ); + return { searchBox, dropdownItems, mappingOptions }; + }; + it('renders column captions', () => { expect(findColumnInRow(0, 0).text()).toContain(i18n.columns.gitlabKeyTitle); expect(findColumnInRow(0, 2).text()).toContain(i18n.columns.payloadKeyTitle); @@ -63,10 +72,7 @@ describe('AlertMappingBuilder', () => { it('renders mapping dropdown for each field', () => { alertFields.forEach(({ types }, index) => { const dropdown = findColumnInRow(index + 1, 2).find(GlDropdown); - const searchBox = dropdown.findComponent(GlSearchBoxByType); - const dropdownItems = dropdown.findAllComponents(GlDropdownItem); - const { nodes } = parsedMapping.samplePayload.payloadAlerFields; - const mappingOptions = nodes.filter(({ type }) => types.includes(type)); + const { searchBox, dropdownItems, mappingOptions } = getDropdownContent(dropdown, types); expect(dropdown.exists()).toBe(true); expect(searchBox.exists()).toBe(true); @@ -80,11 +86,7 @@ describe('AlertMappingBuilder', () => { expect(dropdown.exists()).toBe(Boolean(numberOfFallbacks)); if (numberOfFallbacks) { - const searchBox = dropdown.findComponent(GlSearchBoxByType); - const dropdownItems = dropdown.findAllComponents(GlDropdownItem); - const { nodes } = parsedMapping.samplePayload.payloadAlerFields; - const mappingOptions = nodes.filter(({ type }) => types.includes(type)); - + const { searchBox, dropdownItems, mappingOptions } = getDropdownContent(dropdown, types); expect(searchBox.exists()).toBe(Boolean(numberOfFallbacks)); expect(dropdownItems).toHaveLength(mappingOptions.length); } diff --git a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js index 02229b3d3da..d2dcff14432 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js @@ -1,29 +1,18 @@ -import { - GlForm, - GlFormSelect, - GlCollapse, - GlFormInput, - GlToggle, - GlFormTextarea, -} from '@gitlab/ui'; +import { GlForm, GlFormSelect, GlFormInput, GlToggle, GlFormTextarea, GlTab } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; import MappingBuilder from '~/alerts_settings/components/alert_mapping_builder.vue'; import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue'; import { typeSet } from '~/alerts_settings/constants'; -import alertFields from '../mocks/alertFields.json'; +import alertFields from '../mocks/alert_fields.json'; +import parsedMapping from '../mocks/parsed_mapping.json'; import { defaultAlertSettingsConfig } from './util'; describe('AlertsSettingsForm', () => { let wrapper; const mockToastShow = jest.fn(); - const createComponent = ({ - data = {}, - props = {}, - multipleHttpIntegrationsCustomMapping = false, - multiIntegrations = true, - } = {}) => { + const createComponent = ({ data = {}, props = {}, multiIntegrations = true } = {}) => { wrapper = mount(AlertsSettingsForm, { data() { return { ...data }; @@ -35,10 +24,12 @@ describe('AlertsSettingsForm', () => { }, provide: { ...defaultAlertSettingsConfig, - glFeatures: { multipleHttpIntegrationsCustomMapping }, multiIntegrations, }, mocks: { + $apollo: { + query: jest.fn(), + }, $toast: { show: mockToastShow, }, @@ -46,20 +37,20 @@ describe('AlertsSettingsForm', () => { }); }; - const findForm = () => wrapper.find(GlForm); - const findSelect = () => wrapper.find(GlFormSelect); - const findFormSteps = () => wrapper.find(GlCollapse); - const findFormFields = () => wrapper.findAll(GlFormInput); - const findFormToggle = () => wrapper.find(GlToggle); - const findTestPayloadSection = () => wrapper.find(`[id = "test-integration"]`); + const findForm = () => wrapper.findComponent(GlForm); + const findSelect = () => wrapper.findComponent(GlFormSelect); + const findFormFields = () => wrapper.findAllComponents(GlFormInput); + const findFormToggle = () => wrapper.findComponent(GlToggle); + const findSamplePayloadSection = () => wrapper.find('[data-testid="sample-payload-section"]'); const findMappingBuilderSection = () => wrapper.find(`[id = "mapping-builder"]`); const findMappingBuilder = () => wrapper.findComponent(MappingBuilder); const findSubmitButton = () => wrapper.find(`[type = "submit"]`); const findMultiSupportText = () => wrapper.find(`[data-testid="multi-integrations-not-supported"]`); - const findJsonTestSubmit = () => wrapper.find(`[data-testid="integration-test-and-submit"]`); + const findJsonTestSubmit = () => wrapper.find(`[data-testid="send-test-alert"]`); const findJsonTextArea = () => wrapper.find(`[id = "test-payload"]`); const findActionBtn = () => wrapper.find(`[data-testid="payload-action-btn"]`); + const findTabs = () => wrapper.findAllComponents(GlTab); afterEach(() => { if (wrapper) { @@ -91,7 +82,7 @@ describe('AlertsSettingsForm', () => { expect(findForm().exists()).toBe(true); expect(findSelect().exists()).toBe(true); expect(findMultiSupportText().exists()).toBe(false); - expect(findFormSteps().attributes('visible')).toBeUndefined(); + expect(findFormFields()).toHaveLength(0); }); it('shows the rest of the form when the dropdown is used', async () => { @@ -106,37 +97,47 @@ describe('AlertsSettingsForm', () => { expect(findMultiSupportText().exists()).toBe(true); }); - it('disabled the name input when the selected value is prometheus', async () => { + it('hides the name input when the selected value is prometheus', async () => { createComponent(); await selectOptionAtIndex(2); - - expect(findFormFields().at(0).attributes('disabled')).toBe('disabled'); + expect(findFormFields().at(0).attributes('id')).not.toBe('name-integration'); }); - }); - - describe('submitting integration form', () => { - describe('HTTP', () => { - it('create', async () => { - createComponent(); - const integrationName = 'Test integration'; - await selectOptionAtIndex(1); - enableIntegration(0, integrationName); - - const submitBtn = findSubmitButton(); - expect(submitBtn.exists()).toBe(true); - expect(submitBtn.text()).toBe('Save integration'); + describe('form tabs', () => { + it('renders 3 tabs', () => { + expect(findTabs()).toHaveLength(3); + }); - findForm().trigger('submit'); + it('only first tab is enabled on integration create', () => { + createComponent({ + data: { + currentIntegration: null, + }, + }); + const tabs = findTabs(); + expect(tabs.at(0).find('[role="tabpanel"]').classes('disabled')).toBe(false); + expect(tabs.at(1).find('[role="tabpanel"]').classes('disabled')).toBe(true); + expect(tabs.at(2).find('[role="tabpanel"]').classes('disabled')).toBe(true); + }); - expect(wrapper.emitted('create-new-integration')[0]).toEqual([ - { type: typeSet.http, variables: { name: integrationName, active: true } }, - ]); + it('all tabs are enabled on integration edit', () => { + createComponent({ + data: { + currentIntegration: { id: 1 }, + }, + }); + const tabs = findTabs(); + expect(tabs.at(0).find('[role="tabpanel"]').classes('disabled')).toBe(false); + expect(tabs.at(1).find('[role="tabpanel"]').classes('disabled')).toBe(false); + expect(tabs.at(2).find('[role="tabpanel"]').classes('disabled')).toBe(false); }); + }); + }); + describe('submitting integration form', () => { + describe('HTTP', () => { it('create with custom mapping', async () => { createComponent({ - multipleHttpIntegrationsCustomMapping: true, multiIntegrations: true, props: { alertFields }, }); @@ -146,7 +147,7 @@ describe('AlertsSettingsForm', () => { enableIntegration(0, integrationName); - const sampleMapping = { field: 'test' }; + const sampleMapping = parsedMapping.payloadAttributeMappings; findMappingBuilder().vm.$emit('onMappingUpdate', sampleMapping); findForm().trigger('submit'); @@ -157,7 +158,7 @@ describe('AlertsSettingsForm', () => { name: integrationName, active: true, payloadAttributeMappings: sampleMapping, - payloadExample: null, + payloadExample: '{}', }, }, ]); @@ -182,23 +183,28 @@ describe('AlertsSettingsForm', () => { findForm().trigger('submit'); - expect(wrapper.emitted('update-integration')[0]).toEqual([ - { type: typeSet.http, variables: { name: updatedIntegrationName, active: true } }, - ]); + expect(wrapper.emitted('update-integration')[0]).toEqual( + expect.arrayContaining([ + { + type: typeSet.http, + variables: { + name: updatedIntegrationName, + active: true, + payloadAttributeMappings: [], + payloadExample: '{}', + }, + }, + ]), + ); }); }); describe('PROMETHEUS', () => { it('create', async () => { createComponent(); - await selectOptionAtIndex(2); - const apiUrl = 'https://test.com'; - enableIntegration(1, apiUrl); - - findFormToggle().trigger('click'); - + enableIntegration(0, apiUrl); const submitBtn = findSubmitButton(); expect(submitBtn.exists()).toBe(true); expect(submitBtn.text()).toBe('Save integration'); @@ -222,7 +228,7 @@ describe('AlertsSettingsForm', () => { }); const apiUrl = 'https://test-post.com'; - enableIntegration(1, apiUrl); + enableIntegration(0, apiUrl); const submitBtn = findSubmitButton(); expect(submitBtn.exists()).toBe(true); @@ -260,7 +266,7 @@ describe('AlertsSettingsForm', () => { const jsonTestSubmit = findJsonTestSubmit(); expect(jsonTestSubmit.exists()).toBe(true); - expect(jsonTestSubmit.text()).toBe('Save and test payload'); + expect(jsonTestSubmit.text()).toBe('Send'); expect(jsonTestSubmit.props('disabled')).toBe(true); }); @@ -275,56 +281,73 @@ describe('AlertsSettingsForm', () => { }); describe('Test payload section for HTTP integration', () => { + const validSamplePayload = JSON.stringify(alertFields); + const emptySamplePayload = '{}'; + beforeEach(() => { createComponent({ - multipleHttpIntegrationsCustomMapping: true, - props: { + data: { currentIntegration: { type: typeSet.http, + payloadExample: validSamplePayload, + payloadAttributeMappings: [], }, - alertFields, + active: false, + resetPayloadAndMappingConfirmed: false, }, + props: { alertFields }, }); }); describe.each` - active | resetSamplePayloadConfirmed | disabled - ${true} | ${true} | ${undefined} - ${false} | ${true} | ${'disabled'} - ${true} | ${false} | ${'disabled'} - ${false} | ${false} | ${'disabled'} - `('', ({ active, resetSamplePayloadConfirmed, disabled }) => { - const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed'; + active | resetPayloadAndMappingConfirmed | disabled + ${true} | ${true} | ${undefined} + ${false} | ${true} | ${'disabled'} + ${true} | ${false} | ${'disabled'} + ${false} | ${false} | ${'disabled'} + `('', ({ active, resetPayloadAndMappingConfirmed, disabled }) => { + const payloadResetMsg = resetPayloadAndMappingConfirmed + ? 'was confirmed' + : 'was not confirmed'; const enabledState = disabled === 'disabled' ? 'disabled' : 'enabled'; const activeState = active ? 'active' : 'not active'; it(`textarea should be ${enabledState} when payload reset ${payloadResetMsg} and current integration is ${activeState}`, async () => { wrapper.setData({ - customMapping: { samplePayload: true }, + selectedIntegration: typeSet.http, active, - resetSamplePayloadConfirmed, + resetPayloadAndMappingConfirmed, }); await wrapper.vm.$nextTick(); - expect(findTestPayloadSection().find(GlFormTextarea).attributes('disabled')).toBe(disabled); + expect(findSamplePayloadSection().find(GlFormTextarea).attributes('disabled')).toBe( + disabled, + ); }); }); describe('action buttons for sample payload', () => { describe.each` - resetSamplePayloadConfirmed | samplePayload | caption - ${false} | ${true} | ${'Edit payload'} - ${true} | ${false} | ${'Submit payload'} - ${true} | ${true} | ${'Submit payload'} - ${false} | ${false} | ${'Submit payload'} - `('', ({ resetSamplePayloadConfirmed, samplePayload, caption }) => { - const samplePayloadMsg = samplePayload ? 'was provided' : 'was not provided'; - const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed'; + resetPayloadAndMappingConfirmed | payloadExample | caption + ${false} | ${validSamplePayload} | ${'Edit payload'} + ${true} | ${emptySamplePayload} | ${'Parse payload for custom mapping'} + ${true} | ${validSamplePayload} | ${'Parse payload for custom mapping'} + ${false} | ${emptySamplePayload} | ${'Parse payload for custom mapping'} + `('', ({ resetPayloadAndMappingConfirmed, payloadExample, caption }) => { + const samplePayloadMsg = payloadExample ? 'was provided' : 'was not provided'; + const payloadResetMsg = resetPayloadAndMappingConfirmed + ? 'was confirmed' + : 'was not confirmed'; it(`shows ${caption} button when sample payload ${samplePayloadMsg} and payload reset ${payloadResetMsg}`, async () => { wrapper.setData({ selectedIntegration: typeSet.http, - customMapping: { samplePayload }, - resetSamplePayloadConfirmed, + currentIntegration: { + payloadExample, + type: typeSet.http, + active: true, + payloadAttributeMappings: [], + }, + resetPayloadAndMappingConfirmed, }); await wrapper.vm.$nextTick(); expect(findActionBtn().text()).toBe(caption); @@ -333,16 +356,20 @@ describe('AlertsSettingsForm', () => { }); describe('Parsing payload', () => { - it('displays a toast message on successful parse', async () => { - jest.useFakeTimers(); + beforeEach(() => { wrapper.setData({ selectedIntegration: typeSet.http, - customMapping: { samplePayload: false }, + resetPayloadAndMappingConfirmed: true, }); - await wrapper.vm.$nextTick(); + }); + it('displays a toast message on successful parse', async () => { + jest.spyOn(wrapper.vm.$apollo, 'query').mockResolvedValue({ + data: { + project: { alertManagementPayloadFields: [] }, + }, + }); findActionBtn().vm.$emit('click'); - jest.advanceTimersByTime(1000); await waitForPromises(); @@ -350,27 +377,33 @@ describe('AlertsSettingsForm', () => { 'Sample payload has been parsed. You can now map the fields.', ); }); + + it('displays an error message under payload field on unsuccessful parse', async () => { + const errorMessage = 'Error parsing paylod'; + jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({ message: errorMessage }); + findActionBtn().vm.$emit('click'); + + await waitForPromises(); + + expect(findSamplePayloadSection().find('.invalid-feedback').text()).toBe(errorMessage); + }); }); }); describe('Mapping builder section', () => { describe.each` - alertFieldsProvided | multiIntegrations | featureFlag | integrationOption | visible - ${true} | ${true} | ${true} | ${1} | ${true} - ${true} | ${true} | ${true} | ${2} | ${false} - ${true} | ${true} | ${false} | ${1} | ${false} - ${true} | ${true} | ${false} | ${2} | ${false} - ${true} | ${false} | ${true} | ${1} | ${false} - ${false} | ${true} | ${true} | ${1} | ${false} - `('', ({ alertFieldsProvided, multiIntegrations, featureFlag, integrationOption, visible }) => { + alertFieldsProvided | multiIntegrations | integrationOption | visible + ${true} | ${true} | ${1} | ${true} + ${true} | ${true} | ${2} | ${false} + ${true} | ${false} | ${1} | ${false} + ${false} | ${true} | ${1} | ${false} + `('', ({ alertFieldsProvided, multiIntegrations, integrationOption, visible }) => { const visibleMsg = visible ? 'is rendered' : 'is not rendered'; - const featureFlagMsg = featureFlag ? 'is enabled' : 'is disabled'; const alertFieldsMsg = alertFieldsProvided ? 'are provided' : 'are not provided'; const integrationType = integrationOption === 1 ? typeSet.http : typeSet.prometheus; - it(`${visibleMsg} when multipleHttpIntegrationsCustomMapping feature flag ${featureFlagMsg} and integration type is ${integrationType} and alert fields ${alertFieldsMsg}`, async () => { + it(`${visibleMsg} when integration type is ${integrationType} and alert fields ${alertFieldsMsg}`, async () => { createComponent({ - multipleHttpIntegrationsCustomMapping: featureFlag, multiIntegrations, props: { alertFields: alertFieldsProvided ? alertFields : [], diff --git a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js index 80293597ab6..77fac6dd022 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js @@ -2,21 +2,26 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import VueApollo from 'vue-apollo'; +import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; +import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; import waitForPromises from 'helpers/wait_for_promises'; import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue'; import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue'; -import AlertsSettingsWrapper from '~/alerts_settings/components/alerts_settings_wrapper.vue'; +import AlertsSettingsWrapper, { + i18n, +} from '~/alerts_settings/components/alerts_settings_wrapper.vue'; import { typeSet } from '~/alerts_settings/constants'; -import createHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; import createPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql'; import destroyHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql'; import resetHttpTokenMutation from '~/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql'; import resetPrometheusTokenMutation from '~/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql'; -import updateHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; +import updateCurrentHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/update_current_http_integration.mutation.graphql'; +import updateCurrentPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql'; import updatePrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql'; import getIntegrationsQuery from '~/alerts_settings/graphql/queries/get_integrations.query.graphql'; +import alertsUpdateService from '~/alerts_settings/services'; import { ADD_INTEGRATION_ERROR, RESET_INTEGRATION_TOKEN_ERROR, @@ -24,14 +29,15 @@ import { INTEGRATION_PAYLOAD_TEST_ERROR, DELETE_INTEGRATION_ERROR, } from '~/alerts_settings/utils/error_messages'; -import createFlash from '~/flash'; +import createFlash, { FLASH_TYPES } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { createHttpVariables, updateHttpVariables, createPrometheusVariables, updatePrometheusVariables, - ID, + HTTP_ID, + PROMETHEUS_ID, errorMsg, getIntegrationsQueryResponse, destroyIntegrationResponse, @@ -50,9 +56,33 @@ describe('AlertsSettingsWrapper', () => { let fakeApollo; let destroyIntegrationHandler; useMockIntersectionObserver(); + const httpMappingData = { + payloadExample: '{"test: : "field"}', + payloadAttributeMappings: [], + payloadAlertFields: [], + }; + const httpIntegrations = { + list: [ + { + id: mockIntegrations[0].id, + ...httpMappingData, + }, + { + id: mockIntegrations[1].id, + ...httpMappingData, + }, + { + id: mockIntegrations[2].id, + httpMappingData, + }, + ], + }; - const findLoader = () => wrapper.find(IntegrationsList).find(GlLoadingIcon); + const findLoader = () => wrapper.findComponent(IntegrationsList).findComponent(GlLoadingIcon); + const findIntegrationsList = () => wrapper.findComponent(IntegrationsList); const findIntegrations = () => wrapper.find(IntegrationsList).findAll('table tbody tr'); + const findAddIntegrationBtn = () => wrapper.find('[data-testid="add-integration-btn"]'); + const findAlertsSettingsForm = () => wrapper.findComponent(AlertsSettingsForm); async function destroyHttpIntegration(localWrapper) { await jest.runOnlyPendingTimers(); @@ -119,14 +149,37 @@ describe('AlertsSettingsWrapper', () => { wrapper = null; }); - describe('rendered via default permissions', () => { - it('renders the GraphQL alerts integrations list and new form', () => { - createComponent(); - expect(wrapper.find(IntegrationsList).exists()).toBe(true); - expect(wrapper.find(AlertsSettingsForm).exists()).toBe(true); + describe('template', () => { + beforeEach(() => { + createComponent({ + data: { + integrations: { list: mockIntegrations }, + httpIntegrations: { list: [] }, + currentIntegration: mockIntegrations[0], + }, + loading: false, + }); }); - it('uses a loading state inside the IntegrationsList table', () => { + it('renders alerts integrations list and add new integration button by default', () => { + expect(findLoader().exists()).toBe(false); + expect(findIntegrations()).toHaveLength(mockIntegrations.length); + expect(findAddIntegrationBtn().exists()).toBe(true); + }); + + it('does NOT render settings form by default', () => { + expect(findAlertsSettingsForm().exists()).toBe(false); + }); + + it('hides `add new integration` button and displays setting form on btn click', async () => { + const addNewIntegrationBtn = findAddIntegrationBtn(); + expect(addNewIntegrationBtn.exists()).toBe(true); + await addNewIntegrationBtn.trigger('click'); + expect(findAlertsSettingsForm().exists()).toBe(true); + expect(addNewIntegrationBtn.exists()).toBe(false); + }); + + it('shows loading indicator inside the IntegrationsList table', () => { createComponent({ data: { integrations: {} }, loading: true, @@ -134,26 +187,24 @@ describe('AlertsSettingsWrapper', () => { expect(wrapper.find(IntegrationsList).exists()).toBe(true); expect(findLoader().exists()).toBe(true); }); + }); - it('renders the IntegrationsList table using the API data', () => { + describe('Integration updates', () => { + beforeEach(() => { createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + data: { + integrations: { list: mockIntegrations }, + currentIntegration: mockIntegrations[0], + formVisible: true, + }, loading: false, }); - expect(findLoader().exists()).toBe(false); - expect(findIntegrations()).toHaveLength(mockIntegrations.length); }); - it('calls `$apollo.mutate` with `createHttpIntegrationMutation`', () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { createHttpIntegrationMutation: { integration: { id: '1' } } }, }); - wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', { + findAlertsSettingsForm().vm.$emit('create-new-integration', { type: typeSet.http, variables: createHttpVariables, }); @@ -167,15 +218,10 @@ describe('AlertsSettingsWrapper', () => { }); it('calls `$apollo.mutate` with `updateHttpIntegrationMutation`', () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { updateHttpIntegrationMutation: { integration: { id: '1' } } }, }); - wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', { + findAlertsSettingsForm().vm.$emit('update-integration', { type: typeSet.http, variables: updateHttpVariables, }); @@ -187,37 +233,27 @@ describe('AlertsSettingsWrapper', () => { }); it('calls `$apollo.mutate` with `resetHttpTokenMutation`', () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { resetHttpTokenMutation: { integration: { id: '1' } } }, }); - wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', { + findAlertsSettingsForm().vm.$emit('reset-token', { type: typeSet.http, - variables: { id: ID }, + variables: { id: HTTP_ID }, }); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ mutation: resetHttpTokenMutation, variables: { - id: ID, + id: HTTP_ID, }, }); }); it('calls `$apollo.mutate` with `createPrometheusIntegrationMutation`', () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { createPrometheusIntegrationMutation: { integration: { id: '2' } } }, }); - wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', { + findAlertsSettingsForm().vm.$emit('create-new-integration', { type: typeSet.prometheus, variables: createPrometheusVariables, }); @@ -232,14 +268,18 @@ describe('AlertsSettingsWrapper', () => { it('calls `$apollo.mutate` with `updatePrometheusIntegrationMutation`', () => { createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + data: { + integrations: { list: mockIntegrations }, + currentIntegration: mockIntegrations[3], + formVisible: true, + }, loading: false, }); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { updatePrometheusIntegrationMutation: { integration: { id: '2' } } }, }); - wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', { + findAlertsSettingsForm().vm.$emit('update-integration', { type: typeSet.prometheus, variables: updatePrometheusVariables, }); @@ -251,35 +291,25 @@ describe('AlertsSettingsWrapper', () => { }); it('calls `$apollo.mutate` with `resetPrometheusTokenMutation`', () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ data: { resetPrometheusTokenMutation: { integration: { id: '1' } } }, }); - wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', { + findAlertsSettingsForm().vm.$emit('reset-token', { type: typeSet.prometheus, - variables: { id: ID }, + variables: { id: PROMETHEUS_ID }, }); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ mutation: resetPrometheusTokenMutation, variables: { - id: ID, + id: PROMETHEUS_ID, }, }); }); it('shows an error alert when integration creation fails ', async () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(ADD_INTEGRATION_ERROR); - wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', {}); + findAlertsSettingsForm().vm.$emit('create-new-integration', {}); await waitForPromises(); @@ -287,28 +317,18 @@ describe('AlertsSettingsWrapper', () => { }); it('shows an error alert when integration token reset fails ', async () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(RESET_INTEGRATION_TOKEN_ERROR); - wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', {}); + findAlertsSettingsForm().vm.$emit('reset-token', {}); await waitForPromises(); expect(createFlash).toHaveBeenCalledWith({ message: RESET_INTEGRATION_TOKEN_ERROR }); }); it('shows an error alert when integration update fails ', async () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg); - wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', {}); + findAlertsSettingsForm().vm.$emit('update-integration', {}); await waitForPromises(); expect(createFlash).toHaveBeenCalledWith({ message: UPDATE_INTEGRATION_ERROR }); @@ -317,15 +337,74 @@ describe('AlertsSettingsWrapper', () => { it('shows an error alert when integration test payload fails ', async () => { const mock = new AxiosMockAdapter(axios); mock.onPost(/(.*)/).replyOnce(403); + return wrapper.vm.testAlertPayload({ endpoint: '', data: '', token: '' }).then(() => { + expect(createFlash).toHaveBeenCalledWith({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); + expect(createFlash).toHaveBeenCalledTimes(1); + mock.restore(); + }); + }); + + it('calls `$apollo.mutate` with `updateCurrentHttpIntegrationMutation` on HTTP integration edit', () => { createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + data: { + integrations: { list: mockIntegrations }, + currentIntegration: mockIntegrations[0], + httpIntegrations, + }, loading: false, }); - return wrapper.vm.validateAlertPayload({ endpoint: '', data: '', token: '' }).then(() => { - expect(createFlash).toHaveBeenCalledWith({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); - expect(createFlash).toHaveBeenCalledTimes(1); - mock.restore(); + jest.spyOn(wrapper.vm.$apollo, 'mutate'); + findIntegrationsList().vm.$emit('edit-integration', updateHttpVariables); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updateCurrentHttpIntegrationMutation, + variables: { ...mockIntegrations[0], ...httpMappingData }, + }); + }); + + it('calls `$apollo.mutate` with `updateCurrentPrometheusIntegrationMutation` on PROMETHEUS integration edit', () => { + createComponent({ + data: { + integrations: { list: mockIntegrations }, + currentIntegration: mockIntegrations[3], + httpIntegrations, + }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate'); + findIntegrationsList().vm.$emit('edit-integration', updatePrometheusVariables); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updateCurrentPrometheusIntegrationMutation, + variables: mockIntegrations[3], + }); + }); + + describe('Test alert', () => { + it('makes `updateTestAlert` service call', async () => { + jest.spyOn(alertsUpdateService, 'updateTestAlert').mockResolvedValueOnce(); + const testPayload = '{"title":"test"}'; + findAlertsSettingsForm().vm.$emit('test-alert-payload', testPayload); + expect(alertsUpdateService.updateTestAlert).toHaveBeenCalledWith(testPayload); + }); + + it('shows success message on successful test', async () => { + jest.spyOn(alertsUpdateService, 'updateTestAlert').mockResolvedValueOnce({}); + findAlertsSettingsForm().vm.$emit('test-alert-payload', ''); + await waitForPromises(); + expect(createFlash).toHaveBeenCalledWith({ + message: i18n.alertSent, + type: FLASH_TYPES.SUCCESS, + }); + }); + + it('shows error message when test alert fails', async () => { + jest.spyOn(alertsUpdateService, 'updateTestAlert').mockRejectedValueOnce({}); + findAlertsSettingsForm().vm.$emit('test-alert-payload', ''); + await waitForPromises(); + expect(createFlash).toHaveBeenCalledWith({ + message: INTEGRATION_PAYLOAD_TEST_ERROR, + }); }); }); }); diff --git a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js index e0eba1e8421..828580a436b 100644 --- a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js +++ b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js @@ -1,29 +1,34 @@ const projectPath = ''; -export const ID = 'gid://gitlab/AlertManagement::HttpIntegration/7'; +export const HTTP_ID = 'gid://gitlab/AlertManagement::HttpIntegration/7'; +export const PROMETHEUS_ID = 'gid://gitlab/PrometheusService/12'; export const errorMsg = 'Something went wrong'; export const createHttpVariables = { name: 'Test Pre', active: true, projectPath, + type: 'HTTP', }; export const updateHttpVariables = { name: 'Test Pre', active: true, - id: ID, + id: HTTP_ID, + type: 'HTTP', }; export const createPrometheusVariables = { apiUrl: 'https://test-pre.com', active: true, projectPath, + type: 'PROMETHEUS', }; export const updatePrometheusVariables = { apiUrl: 'https://test-pre.com', active: true, - id: ID, + id: PROMETHEUS_ID, + type: 'PROMETHEUS', }; export const getIntegrationsQueryResponse = { @@ -99,6 +104,9 @@ export const destroyIntegrationResponse = { 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', token: '89eb01df471d990ff5162a1c640408cf', apiUrl: null, + payloadExample: '{"field": "value"}', + payloadAttributeMappings: [], + payloadAlertFields: [], }, }, }, @@ -117,6 +125,9 @@ export const destroyIntegrationResponseWithErrors = { 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', token: '89eb01df471d990ff5162a1c640408cf', apiUrl: null, + payloadExample: '{"field": "value"}', + payloadAttributeMappings: [], + payloadAlertFields: [], }, }, }, diff --git a/spec/frontend/alerts_settings/mocks/alertFields.json b/spec/frontend/alerts_settings/mocks/alert_fields.json index ffe59dd0c05..ffe59dd0c05 100644 --- a/spec/frontend/alerts_settings/mocks/alertFields.json +++ b/spec/frontend/alerts_settings/mocks/alert_fields.json diff --git a/spec/frontend/alerts_settings/mocks/parsed_mapping.json b/spec/frontend/alerts_settings/mocks/parsed_mapping.json new file mode 100644 index 00000000000..e985671a923 --- /dev/null +++ b/spec/frontend/alerts_settings/mocks/parsed_mapping.json @@ -0,0 +1,122 @@ +{ + "payloadAlerFields": [ + { + "path": [ + "dashboardId" + ], + "label": "Dashboard Id", + "type": "string" + }, + { + "path": [ + "evalMatches" + ], + "label": "Eval Matches", + "type": "array" + }, + { + "path": [ + "createdAt" + ], + "label": "Created At", + "type": "datetime" + }, + { + "path": [ + "imageUrl" + ], + "label": "Image Url", + "type": "string" + }, + { + "path": [ + "message" + ], + "label": "Message", + "type": "string" + }, + { + "path": [ + "orgId" + ], + "label": "Org Id", + "type": "string" + }, + { + "path": [ + "panelId" + ], + "label": "Panel Id", + "type": "string" + }, + { + "path": [ + "ruleId" + ], + "label": "Rule Id", + "type": "string" + }, + { + "path": [ + "ruleName" + ], + "label": "Rule Name", + "type": "string" + }, + { + "path": [ + "ruleUrl" + ], + "label": "Rule Url", + "type": "string" + }, + { + "path": [ + "state" + ], + "label": "State", + "type": "string" + }, + { + "path": [ + "title" + ], + "label": "Title", + "type": "string" + }, + { + "path": [ + "tags", + "tag" + ], + "label": "Tags", + "type": "string" + } + ], + "payloadAttributeMappings": [ + { + "fieldName": "title", + "label": "Title", + "type": "STRING", + "path": ["title"] + }, + { + "fieldName": "description", + "label": "description", + "type": "STRING", + "path": ["description"] + }, + { + "fieldName": "hosts", + "label": "Host", + "type": "ARRAY", + "path": ["hosts", "host"] + }, + { + "fieldName": "startTime", + "label": "Created Atd", + "type": "STRING", + "path": ["time", "createdAt"] + } + ] +} diff --git a/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js b/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js index 8c1977ffebe..62b95c6078b 100644 --- a/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js +++ b/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js @@ -1,29 +1,25 @@ -import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json'; -import { - getMappingData, - getPayloadFields, - transformForSave, -} from '~/alerts_settings/utils/mapping_transformations'; -import alertFields from '../mocks/alertFields.json'; +import { getMappingData, transformForSave } from '~/alerts_settings/utils/mapping_transformations'; +import alertFields from '../mocks/alert_fields.json'; +import parsedMapping from '../mocks/parsed_mapping.json'; describe('Mapping Transformation Utilities', () => { const nameField = { label: 'Name', path: ['alert', 'name'], - type: 'string', + type: 'STRING', }; const dashboardField = { label: 'Dashboard Id', path: ['alert', 'dashboardId'], - type: 'string', + type: 'STRING', }; describe('getMappingData', () => { it('should return mapping data', () => { const result = getMappingData( alertFields, - getPayloadFields(parsedMapping.samplePayload.payloadAlerFields.nodes.slice(0, 3)), - parsedMapping.storedMapping.nodes.slice(0, 3), + parsedMapping.payloadAlerFields.slice(0, 3), + parsedMapping.payloadAttributeMappings.slice(0, 3), ); result.forEach((data, index) => { @@ -44,8 +40,8 @@ describe('Mapping Transformation Utilities', () => { const mockMappingData = [ { name: fieldName, - mapping: 'alert_name', - mappingFields: getPayloadFields([dashboardField, nameField]), + mapping: ['alert', 'name'], + mappingFields: [dashboardField, nameField], }, ]; const result = transformForSave(mockMappingData); @@ -61,21 +57,11 @@ describe('Mapping Transformation Utilities', () => { { name: fieldName, mapping: null, - mappingFields: getPayloadFields([nameField, dashboardField]), + mappingFields: [nameField, dashboardField], }, ]; const result = transformForSave(mockMappingData); expect(result).toEqual([]); }); }); - - describe('getPayloadFields', () => { - it('should add name field to each payload field', () => { - const result = getPayloadFields([nameField, dashboardField]); - expect(result).toEqual([ - { ...nameField, name: 'alert_name' }, - { ...dashboardField, name: 'alert_dashboardId' }, - ]); - }); - }); }); diff --git a/spec/frontend/analytics/instance_statistics/components/app_spec.js b/spec/frontend/analytics/instance_statistics/components/app_spec.js deleted file mode 100644 index b945cc20bd6..00000000000 --- a/spec/frontend/analytics/instance_statistics/components/app_spec.js +++ /dev/null @@ -1,45 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue'; -import InstanceStatisticsApp from '~/analytics/instance_statistics/components/app.vue'; -import InstanceStatisticsCountChart from '~/analytics/instance_statistics/components/instance_statistics_count_chart.vue'; -import ProjectsAndGroupsChart from '~/analytics/instance_statistics/components/projects_and_groups_chart.vue'; -import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue'; - -describe('InstanceStatisticsApp', () => { - let wrapper; - - const createComponent = () => { - wrapper = shallowMount(InstanceStatisticsApp); - }; - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('displays the instance counts component', () => { - expect(wrapper.find(InstanceCounts).exists()).toBe(true); - }); - - ['Pipelines', 'Issues & Merge Requests'].forEach((instance) => { - it(`displays the ${instance} chart`, () => { - const chartTitles = wrapper - .findAll(InstanceStatisticsCountChart) - .wrappers.map((chartComponent) => chartComponent.props('chartTitle')); - - expect(chartTitles).toContain(instance); - }); - }); - - it('displays the users chart component', () => { - expect(wrapper.find(UsersChart).exists()).toBe(true); - }); - - it('displays the projects and groups chart component', () => { - expect(wrapper.find(ProjectsAndGroupsChart).exists()).toBe(true); - }); -}); diff --git a/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js deleted file mode 100644 index bbfc65f19b1..00000000000 --- a/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js +++ /dev/null @@ -1,215 +0,0 @@ -import { GlAlert } from '@gitlab/ui'; -import { GlLineChart } from '@gitlab/ui/dist/charts'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import ProjectsAndGroupChart from '~/analytics/instance_statistics/components/projects_and_groups_chart.vue'; -import groupsQuery from '~/analytics/instance_statistics/graphql/queries/groups.query.graphql'; -import projectsQuery from '~/analytics/instance_statistics/graphql/queries/projects.query.graphql'; -import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; -import { mockQueryResponse } from '../apollo_mock_data'; -import { mockCountsData2, roundedSortedCountsMonthlyChartData2 } from '../mock_data'; - -const localVue = createLocalVue(); -localVue.use(VueApollo); - -describe('ProjectsAndGroupChart', () => { - let wrapper; - let queryResponses = { projects: null, groups: null }; - const mockAdditionalData = [{ recordedAt: '2020-07-21', count: 5 }]; - - const createComponent = ({ - loadingError = false, - projects = [], - groups = [], - projectsLoading = false, - groupsLoading = false, - projectsAdditionalData = [], - groupsAdditionalData = [], - } = {}) => { - queryResponses = { - projects: mockQueryResponse({ - key: 'projects', - data: projects, - loading: projectsLoading, - additionalData: projectsAdditionalData, - }), - groups: mockQueryResponse({ - key: 'groups', - data: groups, - loading: groupsLoading, - additionalData: groupsAdditionalData, - }), - }; - - return shallowMount(ProjectsAndGroupChart, { - props: { - startDate: new Date(2020, 9, 26), - endDate: new Date(2020, 10, 1), - totalDataPoints: mockCountsData2.length, - }, - localVue, - apolloProvider: createMockApollo([ - [projectsQuery, queryResponses.projects], - [groupsQuery, queryResponses.groups], - ]), - data() { - return { loadingError }; - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - queryResponses = { - projects: null, - groups: null, - }; - }); - - const findLoader = () => wrapper.find(ChartSkeletonLoader); - const findAlert = () => wrapper.find(GlAlert); - const findChart = () => wrapper.find(GlLineChart); - - describe('while loading', () => { - beforeEach(() => { - wrapper = createComponent({ projectsLoading: true, groupsLoading: true }); - }); - - it('displays the skeleton loader', () => { - expect(findLoader().exists()).toBe(true); - }); - - it('hides the chart', () => { - expect(findChart().exists()).toBe(false); - }); - }); - - describe('while loading 1 data set', () => { - beforeEach(async () => { - wrapper = createComponent({ - projects: mockCountsData2, - groupsLoading: true, - }); - - await wrapper.vm.$nextTick(); - }); - - it('hides the skeleton loader', () => { - expect(findLoader().exists()).toBe(false); - }); - - it('renders the chart', () => { - expect(findChart().exists()).toBe(true); - }); - }); - - describe('without data', () => { - beforeEach(async () => { - wrapper = createComponent({ projects: [] }); - await wrapper.vm.$nextTick(); - }); - - it('renders a no data message', () => { - expect(findAlert().text()).toBe('No data available.'); - }); - - it('hides the skeleton loader', () => { - expect(findLoader().exists()).toBe(false); - }); - - it('does not render the chart', () => { - expect(findChart().exists()).toBe(false); - }); - }); - - describe('with data', () => { - beforeEach(async () => { - wrapper = createComponent({ projects: mockCountsData2 }); - await wrapper.vm.$nextTick(); - }); - - it('hides the skeleton loader', () => { - expect(findLoader().exists()).toBe(false); - }); - - it('renders the chart', () => { - expect(findChart().exists()).toBe(true); - }); - - it('passes the data to the line chart', () => { - expect(findChart().props('data')).toEqual([ - { data: roundedSortedCountsMonthlyChartData2, name: 'Total projects' }, - { data: [], name: 'Total groups' }, - ]); - }); - }); - - describe('with errors', () => { - beforeEach(async () => { - wrapper = createComponent({ loadingError: true }); - await wrapper.vm.$nextTick(); - }); - - it('renders an error message', () => { - expect(findAlert().text()).toBe('No data available.'); - }); - - it('hides the skeleton loader', () => { - expect(findLoader().exists()).toBe(false); - }); - - it('hides the chart', () => { - expect(findChart().exists()).toBe(false); - }); - }); - - describe.each` - metric | loadingState | newData - ${'projects'} | ${{ projectsAdditionalData: mockAdditionalData }} | ${{ projects: mockCountsData2 }} - ${'groups'} | ${{ groupsAdditionalData: mockAdditionalData }} | ${{ groups: mockCountsData2 }} - `('$metric - fetchMore', ({ metric, loadingState, newData }) => { - describe('when the fetchMore query returns data', () => { - beforeEach(async () => { - wrapper = createComponent({ - ...loadingState, - ...newData, - }); - - jest.spyOn(wrapper.vm.$apollo.queries[metric], 'fetchMore'); - await wrapper.vm.$nextTick(); - }); - - it('requests data twice', () => { - expect(queryResponses[metric]).toBeCalledTimes(2); - }); - - it('calls fetchMore', () => { - expect(wrapper.vm.$apollo.queries[metric].fetchMore).toHaveBeenCalledTimes(1); - }); - }); - - describe('when the fetchMore query throws an error', () => { - beforeEach(() => { - wrapper = createComponent({ - ...loadingState, - ...newData, - }); - - jest - .spyOn(wrapper.vm.$apollo.queries[metric], 'fetchMore') - .mockImplementation(jest.fn().mockRejectedValue()); - return wrapper.vm.$nextTick(); - }); - - it('calls fetchMore', () => { - expect(wrapper.vm.$apollo.queries[metric].fetchMore).toHaveBeenCalledTimes(1); - }); - - it('renders an error message', () => { - expect(findAlert().text()).toBe('No data available.'); - }); - }); - }); -}); diff --git a/spec/frontend/analytics/instance_statistics/apollo_mock_data.js b/spec/frontend/analytics/usage_trends/apollo_mock_data.js index 98eabd577ee..98eabd577ee 100644 --- a/spec/frontend/analytics/instance_statistics/apollo_mock_data.js +++ b/spec/frontend/analytics/usage_trends/apollo_mock_data.js diff --git a/spec/frontend/analytics/instance_statistics/components/__snapshots__/instance_statistics_count_chart_spec.js.snap b/spec/frontend/analytics/usage_trends/components/__snapshots__/usage_trends_count_chart_spec.js.snap index 29bcd5f223b..65de69c2692 100644 --- a/spec/frontend/analytics/instance_statistics/components/__snapshots__/instance_statistics_count_chart_spec.js.snap +++ b/spec/frontend/analytics/usage_trends/components/__snapshots__/usage_trends_count_chart_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`InstanceStatisticsCountChart when fetching more data when the fetchMore query returns data passes the data to the line chart 1`] = ` +exports[`UsageTrendsCountChart when fetching more data when the fetchMore query returns data passes the data to the line chart 1`] = ` Array [ Object { "data": Array [ @@ -22,7 +22,7 @@ Array [ ] `; -exports[`InstanceStatisticsCountChart with data passes the data to the line chart 1`] = ` +exports[`UsageTrendsCountChart with data passes the data to the line chart 1`] = ` Array [ Object { "data": Array [ diff --git a/spec/frontend/analytics/usage_trends/components/app_spec.js b/spec/frontend/analytics/usage_trends/components/app_spec.js new file mode 100644 index 00000000000..f0306ea72e3 --- /dev/null +++ b/spec/frontend/analytics/usage_trends/components/app_spec.js @@ -0,0 +1,40 @@ +import { shallowMount } from '@vue/test-utils'; +import UsageTrendsApp from '~/analytics/usage_trends/components/app.vue'; +import UsageCounts from '~/analytics/usage_trends/components/usage_counts.vue'; +import UsageTrendsCountChart from '~/analytics/usage_trends/components/usage_trends_count_chart.vue'; +import UsersChart from '~/analytics/usage_trends/components/users_chart.vue'; + +describe('UsageTrendsApp', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(UsageTrendsApp); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('displays the usage counts component', () => { + expect(wrapper.find(UsageCounts).exists()).toBe(true); + }); + + ['Total projects & groups', 'Pipelines', 'Issues & Merge Requests'].forEach((usage) => { + it(`displays the ${usage} chart`, () => { + const chartTitles = wrapper + .findAll(UsageTrendsCountChart) + .wrappers.map((chartComponent) => chartComponent.props('chartTitle')); + + expect(chartTitles).toContain(usage); + }); + }); + + it('displays the users chart component', () => { + expect(wrapper.find(UsersChart).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js b/spec/frontend/analytics/usage_trends/components/instance_counts_spec.js index 12b5e14b9c4..707d2cc310f 100644 --- a/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js +++ b/spec/frontend/analytics/usage_trends/components/instance_counts_spec.js @@ -1,9 +1,9 @@ import { shallowMount } from '@vue/test-utils'; -import InstanceCounts from '~/analytics/instance_statistics/components/instance_counts.vue'; import MetricCard from '~/analytics/shared/components/metric_card.vue'; -import { mockInstanceCounts } from '../mock_data'; +import UsageCounts from '~/analytics/usage_trends/components/usage_counts.vue'; +import { mockUsageCounts } from '../mock_data'; -describe('InstanceCounts', () => { +describe('UsageCounts', () => { let wrapper; const createComponent = ({ loading = false, data = {} } = {}) => { @@ -15,7 +15,7 @@ describe('InstanceCounts', () => { }, }; - wrapper = shallowMount(InstanceCounts, { + wrapper = shallowMount(UsageCounts, { mocks: { $apollo }, data() { return { @@ -44,11 +44,11 @@ describe('InstanceCounts', () => { describe('with data', () => { beforeEach(() => { - createComponent({ data: { counts: mockInstanceCounts } }); + createComponent({ data: { counts: mockUsageCounts } }); }); it('passes the counts data to the metric card', () => { - expect(findMetricCard().props('metrics')).toEqual(mockInstanceCounts); + expect(findMetricCard().props('metrics')).toEqual(mockUsageCounts); }); }); }); diff --git a/spec/frontend/analytics/instance_statistics/components/instance_statistics_count_chart_spec.js b/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js index e80dcdff426..7c2df3fe8c4 100644 --- a/spec/frontend/analytics/instance_statistics/components/instance_statistics_count_chart_spec.js +++ b/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js @@ -3,8 +3,8 @@ import { GlLineChart } from '@gitlab/ui/dist/charts'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; -import InstanceStatisticsCountChart from '~/analytics/instance_statistics/components/instance_statistics_count_chart.vue'; -import statsQuery from '~/analytics/instance_statistics/graphql/queries/instance_count.query.graphql'; +import UsageTrendsCountChart from '~/analytics/usage_trends/components/usage_trends_count_chart.vue'; +import statsQuery from '~/analytics/usage_trends/graphql/queries/usage_count.query.graphql'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import { mockQueryResponse, mockApolloResponse } from '../apollo_mock_data'; import { mockCountsData1 } from '../mock_data'; @@ -15,7 +15,7 @@ localVue.use(VueApollo); const loadChartErrorMessage = 'My load error message'; const noDataMessage = 'My no data message'; -const queryResponseDataKey = 'instanceStatisticsMeasurements'; +const queryResponseDataKey = 'usageTrendsMeasurements'; const identifier = 'MOCK_QUERY'; const mockQueryConfig = { identifier, @@ -33,12 +33,12 @@ const mockChartConfig = { queries: [mockQueryConfig], }; -describe('InstanceStatisticsCountChart', () => { +describe('UsageTrendsCountChart', () => { let wrapper; let queryHandler; const createComponent = ({ responseHandler }) => { - return shallowMount(InstanceStatisticsCountChart, { + return shallowMount(UsageTrendsCountChart, { localVue, apolloProvider: createMockApollo([[statsQuery, responseHandler]]), propsData: { ...mockChartConfig }, diff --git a/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js b/spec/frontend/analytics/usage_trends/components/users_chart_spec.js index d857b7fae61..6adfcca11ac 100644 --- a/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js +++ b/spec/frontend/analytics/usage_trends/components/users_chart_spec.js @@ -3,8 +3,8 @@ import { GlAreaChart } from '@gitlab/ui/dist/charts'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; -import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue'; -import usersQuery from '~/analytics/instance_statistics/graphql/queries/users.query.graphql'; +import UsersChart from '~/analytics/usage_trends/components/users_chart.vue'; +import usersQuery from '~/analytics/usage_trends/graphql/queries/users.query.graphql'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import { mockQueryResponse } from '../apollo_mock_data'; import { diff --git a/spec/frontend/analytics/instance_statistics/mock_data.js b/spec/frontend/analytics/usage_trends/mock_data.js index e86e552a952..d96dfa26209 100644 --- a/spec/frontend/analytics/instance_statistics/mock_data.js +++ b/spec/frontend/analytics/usage_trends/mock_data.js @@ -1,4 +1,4 @@ -export const mockInstanceCounts = [ +export const mockUsageCounts = [ { key: 'projects', value: 10, label: 'Projects' }, { key: 'groups', value: 20, label: 'Group' }, ]; diff --git a/spec/frontend/analytics/instance_statistics/utils_spec.js b/spec/frontend/analytics/usage_trends/utils_spec.js index 3fd89c7f740..656f310dda7 100644 --- a/spec/frontend/analytics/instance_statistics/utils_spec.js +++ b/spec/frontend/analytics/usage_trends/utils_spec.js @@ -2,7 +2,7 @@ import { getAverageByMonth, getEarliestDate, generateDataKeys, -} from '~/analytics/instance_statistics/utils'; +} from '~/analytics/usage_trends/utils'; import { mockCountsData1, mockCountsData2, diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index d2522a0124a..d6e1b170dd3 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -482,6 +482,30 @@ describe('Api', () => { }); }); + describe('projectShareWithGroup', () => { + it('invites a group to share access with the authenticated project', () => { + const projectId = 1; + const sharedGroupId = 99; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/share`; + const options = { + group_id: sharedGroupId, + group_access: 10, + expires_at: undefined, + }; + + jest.spyOn(axios, 'post'); + + mock.onPost(expectedUrl).reply(200, { + status: 'success', + }); + + return Api.projectShareWithGroup(projectId, options).then(({ data }) => { + expect(data.status).toBe('success'); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, options); + }); + }); + }); + describe('projectMilestones', () => { it('fetches project milestones', (done) => { const projectId = 1; @@ -638,6 +662,30 @@ describe('Api', () => { }); }); + describe('groupShareWithGroup', () => { + it('invites a group to share access with the authenticated group', () => { + const groupId = 1; + const sharedGroupId = 99; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/share`; + const options = { + group_id: sharedGroupId, + group_access: 10, + expires_at: undefined, + }; + + jest.spyOn(axios, 'post'); + + mock.onPost(expectedUrl).reply(200, { + status: 'success', + }); + + return Api.groupShareWithGroup(groupId, options).then(({ data }) => { + expect(data.status).toBe('success'); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, options); + }); + }); + }); + describe('commit', () => { const projectId = 'user/project'; const sha = 'abcd0123'; diff --git a/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap b/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap index bf33aa731ef..2691e11e616 100644 --- a/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap +++ b/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap @@ -7,7 +7,6 @@ exports[`Keep latest artifact checkbox when application keep latest artifact set <b-form-checkbox-stub checked="true" class="gl-form-checkbox" - plain="true" value="true" > <strong diff --git a/spec/frontend/authentication/u2f/authenticate_spec.js b/spec/frontend/authentication/u2f/authenticate_spec.js index bf50ee88035..153d4be56af 100644 --- a/spec/frontend/authentication/u2f/authenticate_spec.js +++ b/spec/frontend/authentication/u2f/authenticate_spec.js @@ -8,8 +8,6 @@ describe('U2FAuthenticate', () => { let container; let component; - preloadFixtures('u2f/authenticate.html'); - beforeEach(() => { loadFixtures('u2f/authenticate.html'); u2fDevice = new MockU2FDevice(); diff --git a/spec/frontend/authentication/u2f/register_spec.js b/spec/frontend/authentication/u2f/register_spec.js index 9cbadbc2fef..a814144ac7a 100644 --- a/spec/frontend/authentication/u2f/register_spec.js +++ b/spec/frontend/authentication/u2f/register_spec.js @@ -8,8 +8,6 @@ describe('U2FRegister', () => { let container; let component; - preloadFixtures('u2f/register.html'); - beforeEach((done) => { loadFixtures('u2f/register.html'); u2fDevice = new MockU2FDevice(); diff --git a/spec/frontend/authentication/webauthn/authenticate_spec.js b/spec/frontend/authentication/webauthn/authenticate_spec.js index 0a82adfd0ee..8b27560bbbe 100644 --- a/spec/frontend/authentication/webauthn/authenticate_spec.js +++ b/spec/frontend/authentication/webauthn/authenticate_spec.js @@ -13,7 +13,6 @@ const mockResponse = { }; describe('WebAuthnAuthenticate', () => { - preloadFixtures('webauthn/authenticate.html'); useMockNavigatorCredentials(); let fallbackElement; diff --git a/spec/frontend/authentication/webauthn/register_spec.js b/spec/frontend/authentication/webauthn/register_spec.js index 1de952d176d..43cd3d7ca34 100644 --- a/spec/frontend/authentication/webauthn/register_spec.js +++ b/spec/frontend/authentication/webauthn/register_spec.js @@ -5,7 +5,6 @@ import MockWebAuthnDevice from './mock_webauthn_device'; import { useMockNavigatorCredentials } from './util'; describe('WebAuthnRegister', () => { - preloadFixtures('webauthn/register.html'); useMockNavigatorCredentials(); const mockResponse = { diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js index edd17cfd810..09270174674 100644 --- a/spec/frontend/awards_handler_spec.js +++ b/spec/frontend/awards_handler_spec.js @@ -60,7 +60,6 @@ describe('AwardsHandler', () => { u: '6.0', }, }; - preloadFixtures('snippets/show.html'); const openAndWaitForEmojiMenu = (sel = '.js-add-award') => { $(sel).eq(0).click(); @@ -189,8 +188,6 @@ describe('AwardsHandler', () => { expect($thumbsUpEmoji.hasClass('active')).toBe(true); expect($thumbsDownEmoji.hasClass('active')).toBe(false); - $thumbsUpEmoji.tooltip(); - $thumbsDownEmoji.tooltip(); awardsHandler.addAward($votesBlock, awardUrl, 'thumbsdown', true); expect($thumbsUpEmoji.hasClass('active')).toBe(false); @@ -218,9 +215,8 @@ describe('AwardsHandler', () => { const $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); $thumbsUpEmoji.attr('data-title', 'sam, jerry, max, and andy'); awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); - $thumbsUpEmoji.tooltip(); - expect($thumbsUpEmoji.data('originalTitle')).toBe('You, sam, jerry, max, and andy'); + expect($thumbsUpEmoji.attr('title')).toBe('You, sam, jerry, max, and andy'); }); it('handles the special case where "You" is not cleanly comma separated', () => { @@ -229,9 +225,8 @@ describe('AwardsHandler', () => { const $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); $thumbsUpEmoji.attr('data-title', 'sam'); awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); - $thumbsUpEmoji.tooltip(); - expect($thumbsUpEmoji.data('originalTitle')).toBe('You and sam'); + expect($thumbsUpEmoji.attr('title')).toBe('You and sam'); }); }); @@ -243,9 +238,8 @@ describe('AwardsHandler', () => { $thumbsUpEmoji.attr('data-title', 'You, sam, jerry, max, and andy'); $thumbsUpEmoji.addClass('active'); awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); - $thumbsUpEmoji.tooltip(); - expect($thumbsUpEmoji.data('originalTitle')).toBe('sam, jerry, max, and andy'); + expect($thumbsUpEmoji.attr('title')).toBe('sam, jerry, max, and andy'); }); it('handles the special case where "You" is not cleanly comma separated', () => { @@ -255,9 +249,8 @@ describe('AwardsHandler', () => { $thumbsUpEmoji.attr('data-title', 'You and sam'); $thumbsUpEmoji.addClass('active'); awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); - $thumbsUpEmoji.tooltip(); - expect($thumbsUpEmoji.data('originalTitle')).toBe('sam'); + expect($thumbsUpEmoji.attr('title')).toBe('sam'); }); }); diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js index 885e02ef60f..da19265ce82 100644 --- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js @@ -1,6 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; +import service from '~/batch_comments/services/drafts_service'; import * as actions from '~/batch_comments/stores/modules/batch_comments/actions'; import axios from '~/lib/utils/axios_utils'; @@ -201,6 +202,12 @@ describe('Batch comments store actions', () => { describe('updateDraft', () => { let getters; + service.update = jest.fn(); + service.update.mockResolvedValue({ data: { id: 1 } }); + + const commit = jest.fn(); + let context; + let params; beforeEach(() => { getters = { @@ -208,43 +215,43 @@ describe('Batch comments store actions', () => { draftsPath: TEST_HOST, }, }; - }); - it('commits RECEIVE_DRAFT_UPDATE_SUCCESS with returned data', (done) => { - const commit = jest.fn(); - const context = { + context = { getters, commit, }; res = { id: 1 }; mock.onAny().reply(200, res); + params = { note: { id: 1 }, noteText: 'test' }; + }); - actions - .updateDraft(context, { note: { id: 1 }, noteText: 'test', callback() {} }) - .then(() => { - expect(commit).toHaveBeenCalledWith('RECEIVE_DRAFT_UPDATE_SUCCESS', { id: 1 }); - }) - .then(done) - .catch(done.fail); + afterEach(() => jest.clearAllMocks()); + + it('commits RECEIVE_DRAFT_UPDATE_SUCCESS with returned data', () => { + return actions.updateDraft(context, { ...params, callback() {} }).then(() => { + expect(commit).toHaveBeenCalledWith('RECEIVE_DRAFT_UPDATE_SUCCESS', { id: 1 }); + }); }); - it('calls passed callback', (done) => { - const commit = jest.fn(); - const context = { - getters, - commit, - }; + it('calls passed callback', () => { const callback = jest.fn(); - res = { id: 1 }; - mock.onAny().reply(200, res); + return actions.updateDraft(context, { ...params, callback }).then(() => { + expect(callback).toHaveBeenCalled(); + }); + }); - actions - .updateDraft(context, { note: { id: 1 }, noteText: 'test', callback }) - .then(() => { - expect(callback).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + it('does not stringify empty position', () => { + return actions.updateDraft(context, { ...params, position: {}, callback() {} }).then(() => { + expect(service.update.mock.calls[0][1].position).toBeUndefined(); + }); + }); + + it('stringifies a non-empty position', () => { + const position = { test: true }; + const expectation = JSON.stringify(position); + return actions.updateDraft(context, { ...params, position, callback() {} }).then(() => { + expect(service.update.mock.calls[0][1].position).toBe(expectation); + }); }); }); diff --git a/spec/frontend/behaviors/quick_submit_spec.js b/spec/frontend/behaviors/quick_submit_spec.js index d3d65892aff..86a85831c6b 100644 --- a/spec/frontend/behaviors/quick_submit_spec.js +++ b/spec/frontend/behaviors/quick_submit_spec.js @@ -6,8 +6,6 @@ describe('Quick Submit behavior', () => { const keydownEvent = (options = { keyCode: 13, metaKey: true }) => $.Event('keydown', options); - preloadFixtures('snippets/show.html'); - beforeEach(() => { loadFixtures('snippets/show.html'); diff --git a/spec/frontend/behaviors/requires_input_spec.js b/spec/frontend/behaviors/requires_input_spec.js index 0f27f89d6dc..bb22133ae44 100644 --- a/spec/frontend/behaviors/requires_input_spec.js +++ b/spec/frontend/behaviors/requires_input_spec.js @@ -3,7 +3,6 @@ import '~/behaviors/requires_input'; describe('requiresInput', () => { let submitButton; - preloadFixtures('branches/new_branch.html'); beforeEach(() => { loadFixtures('branches/new_branch.html'); diff --git a/spec/frontend/behaviors/shortcuts/keybindings_spec.js b/spec/frontend/behaviors/shortcuts/keybindings_spec.js index d05b3fbdce2..53ce06e78c6 100644 --- a/spec/frontend/behaviors/shortcuts/keybindings_spec.js +++ b/spec/frontend/behaviors/shortcuts/keybindings_spec.js @@ -1,33 +1,53 @@ +import { flatten } from 'lodash'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { + keysFor, + getCustomizations, + keybindingGroups, + TOGGLE_PERFORMANCE_BAR, + LOCAL_STORAGE_KEY, + WEB_IDE_COMMIT, +} from '~/behaviors/shortcuts/keybindings'; -describe('~/behaviors/shortcuts/keybindings.js', () => { - let keysFor; - let TOGGLE_PERFORMANCE_BAR; - let LOCAL_STORAGE_KEY; - +describe('~/behaviors/shortcuts/keybindings', () => { beforeAll(() => { useLocalStorageSpy(); }); - const setupCustomizations = async (customizationsAsString) => { + const setupCustomizations = (customizationsAsString) => { localStorage.clear(); if (customizationsAsString) { localStorage.setItem(LOCAL_STORAGE_KEY, customizationsAsString); } - jest.resetModules(); - ({ keysFor, TOGGLE_PERFORMANCE_BAR, LOCAL_STORAGE_KEY } = await import( - '~/behaviors/shortcuts/keybindings' - )); + getCustomizations.cache.clear(); }; + describe('keybinding definition errors', () => { + beforeEach(() => { + setupCustomizations(); + }); + + it('has no duplicate group IDs', () => { + const allGroupIds = keybindingGroups.map((group) => group.id); + expect(allGroupIds).toHaveLength(new Set(allGroupIds).size); + }); + + it('has no duplicate commands IDs', () => { + const allCommandIds = flatten( + keybindingGroups.map((group) => group.keybindings.map((kb) => kb.id)), + ); + expect(allCommandIds).toHaveLength(new Set(allCommandIds).size); + }); + }); + describe('when a command has not been customized', () => { - beforeEach(async () => { - await setupCustomizations('{}'); + beforeEach(() => { + setupCustomizations('{}'); }); - it('returns the default keybinding for the command', () => { + it('returns the default keybindings for the command', () => { expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(['p b']); }); }); @@ -35,18 +55,30 @@ describe('~/behaviors/shortcuts/keybindings.js', () => { describe('when a command has been customized', () => { const customization = ['p b a r']; - beforeEach(async () => { - await setupCustomizations(JSON.stringify({ [TOGGLE_PERFORMANCE_BAR]: customization })); + beforeEach(() => { + setupCustomizations(JSON.stringify({ [TOGGLE_PERFORMANCE_BAR.id]: customization })); }); - it('returns the default keybinding for the command', () => { + it('returns the custom keybindings for the command', () => { expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(customization); }); }); + describe('when a command is marked as non-customizable', () => { + const customization = ['mod+shift+c']; + + beforeEach(() => { + setupCustomizations(JSON.stringify({ [WEB_IDE_COMMIT.id]: customization })); + }); + + it('returns the default keybinding for the command', () => { + expect(keysFor(WEB_IDE_COMMIT)).toEqual(['mod+enter']); + }); + }); + describe("when the localStorage entry isn't valid JSON", () => { - beforeEach(async () => { - await setupCustomizations('{'); + beforeEach(() => { + setupCustomizations('{'); }); it('returns the default keybinding for the command', () => { @@ -55,8 +87,8 @@ describe('~/behaviors/shortcuts/keybindings.js', () => { }); describe(`when localStorage doesn't contain the ${LOCAL_STORAGE_KEY} key`, () => { - beforeEach(async () => { - await setupCustomizations(); + beforeEach(() => { + setupCustomizations(); }); it('returns the default keybinding for the command', () => { diff --git a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js index 94ba1615c89..26d38b115b6 100644 --- a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js +++ b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js @@ -13,8 +13,6 @@ describe('ShortcutsIssuable', () => { const snippetShowFixtureName = 'snippets/show.html'; const mrShowFixtureName = 'merge_requests/merge_request_of_current_user.html'; - preloadFixtures(snippetShowFixtureName, mrShowFixtureName); - beforeAll((done) => { initCopyAsGFM(); diff --git a/spec/frontend/blob/blob_file_dropzone_spec.js b/spec/frontend/blob/blob_file_dropzone_spec.js index cbd36abd4ff..47c90030e18 100644 --- a/spec/frontend/blob/blob_file_dropzone_spec.js +++ b/spec/frontend/blob/blob_file_dropzone_spec.js @@ -2,7 +2,6 @@ import $ from 'jquery'; import BlobFileDropzone from '~/blob/blob_file_dropzone'; describe('BlobFileDropzone', () => { - preloadFixtures('blob/show.html'); let dropzone; let replaceFileButton; diff --git a/spec/frontend/blob/sketch/index_spec.js b/spec/frontend/blob/sketch/index_spec.js index a24e7de9037..7424897b22c 100644 --- a/spec/frontend/blob/sketch/index_spec.js +++ b/spec/frontend/blob/sketch/index_spec.js @@ -4,8 +4,6 @@ import SketchLoader from '~/blob/sketch'; jest.mock('jszip'); describe('Sketch viewer', () => { - preloadFixtures('static/sketch_viewer.html'); - beforeEach(() => { loadFixtures('static/sketch_viewer.html'); }); diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js index 7449de48ec0..e4f145ae81b 100644 --- a/spec/frontend/blob/viewer/index_spec.js +++ b/spec/frontend/blob/viewer/index_spec.js @@ -16,8 +16,6 @@ describe('Blob viewer', () => { setTestTimeout(2000); - preloadFixtures('blob/show_readme.html'); - beforeEach(() => { $.fn.extend(jQueryMock); mock = new MockAdapter(axios); @@ -85,9 +83,11 @@ describe('Blob viewer', () => { describe('copy blob button', () => { let copyButton; + let copyButtonTooltip; beforeEach(() => { copyButton = document.querySelector('.js-copy-blob-source-btn'); + copyButtonTooltip = document.querySelector('.js-copy-blob-source-btn-tooltip'); }); it('disabled on load', () => { @@ -95,7 +95,7 @@ describe('Blob viewer', () => { }); it('has tooltip when disabled', () => { - expect(copyButton.getAttribute('title')).toBe( + expect(copyButtonTooltip.getAttribute('title')).toBe( 'Switch to the source to copy the file contents', ); }); @@ -131,7 +131,7 @@ describe('Blob viewer', () => { document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); setImmediate(() => { - expect(copyButton.getAttribute('title')).toBe('Copy file contents'); + expect(copyButtonTooltip.getAttribute('title')).toBe('Copy file contents'); done(); }); diff --git a/spec/frontend/boards/issue_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index b9f84fed6b3..4487fc15de6 100644 --- a/spec/frontend/boards/issue_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -1,7 +1,7 @@ import { GlLabel } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { range } from 'lodash'; -import IssueCardInner from '~/boards/components/issue_card_inner.vue'; +import BoardCardInner from '~/boards/components/board_card_inner.vue'; import eventHub from '~/boards/eventhub'; import defaultStore from '~/boards/stores'; import { updateHistory } from '~/lib/utils/url_utility'; @@ -10,7 +10,7 @@ import { mockLabelList } from './mock_data'; jest.mock('~/lib/utils/url_utility'); jest.mock('~/boards/eventhub'); -describe('Issue card component', () => { +describe('Board card component', () => { const user = { id: 1, name: 'testing 123', @@ -31,18 +31,17 @@ describe('Issue card component', () => { let list; const createWrapper = (props = {}, store = defaultStore) => { - wrapper = mount(IssueCardInner, { + wrapper = mount(BoardCardInner, { store, propsData: { list, - issue, + item: issue, ...props, }, stubs: { GlLabel: true, }, provide: { - groupId: null, rootPath: '/', scopedLabelsAvailable: false, }, @@ -63,7 +62,7 @@ describe('Issue card component', () => { weight: 1, }; - createWrapper({ issue, list }); + createWrapper({ item: issue, list }); }); afterEach(() => { @@ -103,8 +102,8 @@ describe('Issue card component', () => { describe('confidential issue', () => { beforeEach(() => { wrapper.setProps({ - issue: { - ...wrapper.props('issue'), + item: { + ...wrapper.props('item'), confidential: true, }, }); @@ -119,8 +118,8 @@ describe('Issue card component', () => { describe('with avatar', () => { beforeEach(() => { wrapper.setProps({ - issue: { - ...wrapper.props('issue'), + item: { + ...wrapper.props('item'), assignees: [user], updateData(newData) { Object.assign(this, newData); @@ -146,8 +145,8 @@ describe('Issue card component', () => { }); it('renders the avatar using avatarUrl property', async () => { - wrapper.props('issue').updateData({ - ...wrapper.props('issue'), + wrapper.props('item').updateData({ + ...wrapper.props('item'), assignees: [ { id: '1', @@ -172,8 +171,8 @@ describe('Issue card component', () => { global.gon.default_avatar_url = 'default_avatar'; wrapper.setProps({ - issue: { - ...wrapper.props('issue'), + item: { + ...wrapper.props('item'), assignees: [ { id: 1, @@ -201,8 +200,8 @@ describe('Issue card component', () => { describe('multiple assignees', () => { beforeEach(() => { wrapper.setProps({ - issue: { - ...wrapper.props('issue'), + item: { + ...wrapper.props('item'), assignees: [ { id: 2, @@ -233,7 +232,7 @@ describe('Issue card component', () => { describe('more than three assignees', () => { beforeEach(() => { - const { assignees } = wrapper.props('issue'); + const { assignees } = wrapper.props('item'); assignees.push({ id: 5, name: 'user5', @@ -242,8 +241,8 @@ describe('Issue card component', () => { }); wrapper.setProps({ - issue: { - ...wrapper.props('issue'), + item: { + ...wrapper.props('item'), assignees, }, }); @@ -259,7 +258,7 @@ describe('Issue card component', () => { it('renders 99+ avatar counter', async () => { const assignees = [ - ...wrapper.props('issue').assignees, + ...wrapper.props('item').assignees, ...range(5, 103).map((i) => ({ id: i, name: 'name', @@ -268,8 +267,8 @@ describe('Issue card component', () => { })), ]; wrapper.setProps({ - issue: { - ...wrapper.props('issue'), + item: { + ...wrapper.props('item'), assignees, }, }); @@ -283,7 +282,7 @@ describe('Issue card component', () => { describe('labels', () => { beforeEach(() => { - wrapper.setProps({ issue: { ...issue, labels: [list.label, label1] } }); + wrapper.setProps({ item: { ...issue, labels: [list.label, label1] } }); }); it('does not render list label but renders all other labels', () => { @@ -295,7 +294,7 @@ describe('Issue card component', () => { }); it('does not render label if label does not have an ID', async () => { - wrapper.setProps({ issue: { ...issue, labels: [label1, { title: 'closed' }] } }); + wrapper.setProps({ item: { ...issue, labels: [label1, { title: 'closed' }] } }); await wrapper.vm.$nextTick(); @@ -307,8 +306,8 @@ describe('Issue card component', () => { describe('blocked', () => { beforeEach(() => { wrapper.setProps({ - issue: { - ...wrapper.props('issue'), + item: { + ...wrapper.props('item'), blocked: true, }, }); diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index 7ed20f20882..bf39c3f3e42 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -1,8 +1,9 @@ -import { createLocalVue, mount } from '@vue/test-utils'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; import BoardCard from '~/boards/components/board_card.vue'; import BoardList from '~/boards/components/board_list.vue'; +import BoardNewIssue from '~/boards/components/board_new_issue.vue'; import eventHub from '~/boards/eventhub'; import defaultState from '~/boards/stores/state'; import { mockList, mockIssuesByListId, issues, mockIssues } from './mock_data'; @@ -11,13 +12,18 @@ const localVue = createLocalVue(); localVue.use(Vuex); const actions = { - fetchIssuesForList: jest.fn(), + fetchItemsForList: jest.fn(), }; const createStore = (state = defaultState) => { return new Vuex.Store({ state, actions, + getters: { + isGroupBoard: () => false, + isProjectBoard: () => true, + isEpicBoard: () => false, + }, }); }; @@ -28,8 +34,8 @@ const createComponent = ({ state = {}, } = {}) => { const store = createStore({ - issuesByListId: mockIssuesByListId, - issues, + boardItemsByListId: mockIssuesByListId, + boardItems: issues, pageInfoByListId: { 'gid://gitlab/List/1': { hasNextPage: true }, 'gid://gitlab/List/2': {}, @@ -38,6 +44,7 @@ const createComponent = ({ 'gid://gitlab/List/1': {}, 'gid://gitlab/List/2': {}, }, + selectedBoardItems: [], ...state, }); @@ -58,12 +65,12 @@ const createComponent = ({ list.issuesCount = 1; } - const component = mount(BoardList, { + const component = shallowMount(BoardList, { localVue, propsData: { disabled: false, list, - issues: [issue], + boardItems: [issue], canAdminList: true, ...componentProps, }, @@ -74,6 +81,10 @@ const createComponent = ({ weightFeatureAvailable: false, boardWeight: null, }, + stubs: { + BoardCard, + BoardNewIssue, + }, }); return component; @@ -81,7 +92,10 @@ const createComponent = ({ describe('Board list component', () => { let wrapper; + const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); + const findIssueCountLoadingIcon = () => wrapper.find('[data-testid="count-loading-icon"]'); + useFakeRequestAnimationFrame(); afterEach(() => { @@ -111,7 +125,7 @@ describe('Board list component', () => { }); it('sets data attribute with issue id', () => { - expect(wrapper.find('.board-card').attributes('data-issue-id')).toBe('1'); + expect(wrapper.find('.board-card').attributes('data-item-id')).toBe('1'); }); it('shows new issue form', async () => { @@ -170,7 +184,7 @@ describe('Board list component', () => { it('loads more issues after scrolling', () => { wrapper.vm.listRef.dispatchEvent(new Event('scroll')); - expect(actions.fetchIssuesForList).toHaveBeenCalled(); + expect(actions.fetchItemsForList).toHaveBeenCalled(); }); it('does not load issues if already loading', () => { @@ -179,7 +193,7 @@ describe('Board list component', () => { }); wrapper.vm.listRef.dispatchEvent(new Event('scroll')); - expect(actions.fetchIssuesForList).not.toHaveBeenCalled(); + expect(actions.fetchItemsForList).not.toHaveBeenCalled(); }); it('shows loading more spinner', async () => { @@ -189,7 +203,8 @@ describe('Board list component', () => { wrapper.vm.showCount = true; await wrapper.vm.$nextTick(); - expect(wrapper.find('.board-list-count .gl-spinner').exists()).toBe(true); + + expect(findIssueCountLoadingIcon().exists()).toBe(true); }); }); @@ -243,7 +258,7 @@ describe('Board list component', () => { describe('handleDragOnEnd', () => { it('removes class `is-dragging` from document body', () => { - jest.spyOn(wrapper.vm, 'moveIssue').mockImplementation(() => {}); + jest.spyOn(wrapper.vm, 'moveItem').mockImplementation(() => {}); document.body.classList.add('is-dragging'); findByTestId('tree-root-wrapper').vm.$emit('end', { @@ -251,9 +266,9 @@ describe('Board list component', () => { newIndex: 0, item: { dataset: { - issueId: mockIssues[0].id, - issueIid: mockIssues[0].iid, - issuePath: mockIssues[0].referencePath, + itemId: mockIssues[0].id, + itemIid: mockIssues[0].iid, + itemPath: mockIssues[0].referencePath, }, }, to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } }, diff --git a/spec/frontend/boards/board_new_issue_deprecated_spec.js b/spec/frontend/boards/board_new_issue_deprecated_spec.js index 1a29f680166..3903ad201b2 100644 --- a/spec/frontend/boards/board_new_issue_deprecated_spec.js +++ b/spec/frontend/boards/board_new_issue_deprecated_spec.js @@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; +import Vuex from 'vuex'; import boardNewIssue from '~/boards/components/board_new_issue_deprecated.vue'; import boardsStore from '~/boards/stores/boards_store'; import axios from '~/lib/utils/axios_utils'; @@ -10,6 +11,8 @@ import axios from '~/lib/utils/axios_utils'; import '~/boards/models/list'; import { listObj, boardsMockInterceptor } from './mock_data'; +Vue.use(Vuex); + describe('Issue boards new issue form', () => { let wrapper; let vm; @@ -43,11 +46,16 @@ describe('Issue boards new issue form', () => { newIssueMock = Promise.resolve(promiseReturn); jest.spyOn(list, 'newIssue').mockImplementation(() => newIssueMock); + const store = new Vuex.Store({ + getters: { isGroupBoard: () => false }, + }); + wrapper = mount(BoardNewIssueComp, { propsData: { disabled: false, list, }, + store, provide: { groupId: null, }, diff --git a/spec/frontend/boards/components/board_add_new_column_form_spec.js b/spec/frontend/boards/components/board_add_new_column_form_spec.js new file mode 100644 index 00000000000..3702f55f17b --- /dev/null +++ b/spec/frontend/boards/components/board_add_new_column_form_spec.js @@ -0,0 +1,166 @@ +import { GlFormGroup, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue'; +import defaultState from '~/boards/stores/state'; +import { mockLabelList } from '../mock_data'; + +Vue.use(Vuex); + +describe('Board card layout', () => { + let wrapper; + + const createStore = ({ actions = {}, getters = {}, state = {} } = {}) => { + return new Vuex.Store({ + state: { + ...defaultState, + ...state, + }, + actions, + getters, + }); + }; + + const mountComponent = ({ + loading = false, + formDescription = '', + searchLabel = '', + searchPlaceholder = '', + selectedId, + actions, + slots, + } = {}) => { + wrapper = extendedWrapper( + shallowMount(BoardAddNewColumnForm, { + stubs: { + GlFormGroup: true, + }, + propsData: { + loading, + formDescription, + searchLabel, + searchPlaceholder, + selectedId, + }, + slots, + store: createStore({ + actions: { + setAddColumnFormVisibility: jest.fn(), + ...actions, + }, + }), + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const formTitle = () => wrapper.findByTestId('board-add-column-form-title').text(); + const findSearchInput = () => wrapper.find(GlSearchBoxByType); + const findSearchLabel = () => wrapper.find(GlFormGroup); + const cancelButton = () => wrapper.findByTestId('cancelAddNewColumn'); + const submitButton = () => wrapper.findByTestId('addNewColumnButton'); + + it('shows form title & search input', () => { + mountComponent(); + + expect(formTitle()).toEqual(BoardAddNewColumnForm.i18n.newList); + expect(findSearchInput().exists()).toBe(true); + }); + + it('clicking cancel hides the form', () => { + const setAddColumnFormVisibility = jest.fn(); + mountComponent({ + actions: { + setAddColumnFormVisibility, + }, + }); + + cancelButton().vm.$emit('click'); + + expect(setAddColumnFormVisibility).toHaveBeenCalledWith(expect.anything(), false); + }); + + it('sets placeholder and description from props', () => { + const props = { + formDescription: 'Some description of a list', + }; + + mountComponent(props); + + expect(wrapper.html()).toHaveText(props.formDescription); + }); + + describe('items', () => { + const mountWithItems = (loading) => + mountComponent({ + loading, + slots: { + items: '<div class="item-slot">Some kind of list</div>', + }, + }); + + it('hides items slot and shows skeleton while loading', () => { + mountWithItems(true); + + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); + expect(wrapper.find('.item-slot').exists()).toBe(false); + }); + + it('shows items slot and hides skeleton while not loading', () => { + mountWithItems(false); + + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false); + expect(wrapper.find('.item-slot').exists()).toBe(true); + }); + }); + + describe('search box', () => { + it('sets label and placeholder text from props', () => { + const props = { + searchLabel: 'Some items', + searchPlaceholder: 'Search for an item', + }; + + mountComponent(props); + + expect(findSearchLabel().attributes('label')).toEqual(props.searchLabel); + expect(findSearchInput().attributes('placeholder')).toEqual(props.searchPlaceholder); + }); + + it('emits filter event on input', () => { + mountComponent(); + + const searchText = 'some text'; + + findSearchInput().vm.$emit('input', searchText); + + expect(wrapper.emitted('filter-items')).toEqual([[searchText]]); + }); + }); + + describe('Add list button', () => { + it('is disabled if no item is selected', () => { + mountComponent(); + + expect(submitButton().props('disabled')).toBe(true); + }); + + it('emits add-list event on click', async () => { + mountComponent({ + selectedId: mockLabelList.label.id, + }); + + await nextTick(); + + submitButton().vm.$emit('click'); + + expect(wrapper.emitted('add-list')).toEqual([[]]); + }); + }); +}); diff --git a/spec/frontend/boards/components/board_add_new_column_spec.js b/spec/frontend/boards/components/board_add_new_column_spec.js new file mode 100644 index 00000000000..60584eaf6cf --- /dev/null +++ b/spec/frontend/boards/components/board_add_new_column_spec.js @@ -0,0 +1,115 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import BoardAddNewColumn from '~/boards/components/board_add_new_column.vue'; +import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue'; +import defaultState from '~/boards/stores/state'; +import { mockLabelList } from '../mock_data'; + +Vue.use(Vuex); + +describe('Board card layout', () => { + let wrapper; + + const createStore = ({ actions = {}, getters = {}, state = {} } = {}) => { + return new Vuex.Store({ + state: { + ...defaultState, + ...state, + }, + actions, + getters, + }); + }; + + const mountComponent = ({ + selectedId, + labels = [], + getListByLabelId = jest.fn(), + actions = {}, + } = {}) => { + wrapper = extendedWrapper( + shallowMount(BoardAddNewColumn, { + data() { + return { + selectedId, + }; + }, + store: createStore({ + actions: { + fetchLabels: jest.fn(), + setAddColumnFormVisibility: jest.fn(), + ...actions, + }, + getters: { + shouldUseGraphQL: () => true, + getListByLabelId: () => getListByLabelId, + }, + state: { + labels, + labelsLoading: false, + isEpicBoard: false, + }, + }), + provide: { + scopedLabelsAvailable: true, + }, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Add list button', () => { + it('calls addList', async () => { + const getListByLabelId = jest.fn().mockReturnValue(null); + const highlightList = jest.fn(); + const createList = jest.fn(); + + mountComponent({ + labels: [mockLabelList.label], + selectedId: mockLabelList.label.id, + getListByLabelId, + actions: { + createList, + highlightList, + }, + }); + + wrapper.findComponent(BoardAddNewColumnForm).vm.$emit('add-list'); + + await nextTick(); + + expect(highlightList).not.toHaveBeenCalled(); + expect(createList).toHaveBeenCalledWith(expect.anything(), { + labelId: mockLabelList.label.id, + }); + }); + + it('highlights existing list if trying to re-add', async () => { + const getListByLabelId = jest.fn().mockReturnValue(mockLabelList); + const highlightList = jest.fn(); + const createList = jest.fn(); + + mountComponent({ + labels: [mockLabelList.label], + selectedId: mockLabelList.label.id, + getListByLabelId, + actions: { + createList, + highlightList, + }, + }); + + wrapper.findComponent(BoardAddNewColumnForm).vm.$emit('add-list'); + + await nextTick(); + + expect(highlightList).toHaveBeenCalledWith(expect.anything(), mockLabelList.id); + expect(createList).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/boards/components/board_card_deprecated_spec.js b/spec/frontend/boards/components/board_card_deprecated_spec.js new file mode 100644 index 00000000000..266cbc7106d --- /dev/null +++ b/spec/frontend/boards/components/board_card_deprecated_spec.js @@ -0,0 +1,219 @@ +/* global List */ +/* global ListAssignee */ +/* global ListLabel */ + +import { mount } from '@vue/test-utils'; + +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import BoardCardDeprecated from '~/boards/components/board_card_deprecated.vue'; +import issueCardInner from '~/boards/components/issue_card_inner_deprecated.vue'; +import eventHub from '~/boards/eventhub'; +import store from '~/boards/stores'; +import boardsStore from '~/boards/stores/boards_store'; +import axios from '~/lib/utils/axios_utils'; + +import sidebarEventHub from '~/sidebar/event_hub'; +import '~/boards/models/label'; +import '~/boards/models/assignee'; +import '~/boards/models/list'; +import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data'; + +describe('BoardCard', () => { + let wrapper; + let mock; + let list; + + const findIssueCardInner = () => wrapper.find(issueCardInner); + const findUserAvatarLink = () => wrapper.find(userAvatarLink); + + // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized + const mountComponent = (propsData) => { + wrapper = mount(BoardCardDeprecated, { + stubs: { + issueCardInner, + }, + store, + propsData: { + list, + issue: list.issues[0], + disabled: false, + index: 0, + ...propsData, + }, + provide: { + groupId: null, + rootPath: '/', + scopedLabelsAvailable: false, + }, + }); + }; + + const setupData = async () => { + list = new List(listObj); + boardsStore.create(); + boardsStore.detail.issue = {}; + const label1 = new ListLabel({ + id: 3, + title: 'testing 123', + color: '#000cff', + text_color: 'white', + description: 'test', + }); + await waitForPromises(); + + list.issues[0].labels.push(label1); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onAny().reply(boardsMockInterceptor); + setMockEndpoints(); + return setupData(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + list = null; + mock.restore(); + }); + + it('when details issue is empty does not show the element', () => { + mountComponent(); + expect(wrapper.find('[data-testid="board_card"').classes()).not.toContain('is-active'); + }); + + it('when detailIssue is equal to card issue shows the element', () => { + [boardsStore.detail.issue] = list.issues; + mountComponent(); + + expect(wrapper.classes()).toContain('is-active'); + }); + + it('when multiSelect does not contain issue removes multi select class', () => { + mountComponent(); + expect(wrapper.classes()).not.toContain('multi-select'); + }); + + it('when multiSelect contain issue add multi select class', () => { + boardsStore.multiSelect.list = [list.issues[0]]; + mountComponent(); + + expect(wrapper.classes()).toContain('multi-select'); + }); + + it('adds user-can-drag class if not disabled', () => { + mountComponent(); + expect(wrapper.classes()).toContain('user-can-drag'); + }); + + it('does not add user-can-drag class disabled', () => { + mountComponent({ disabled: true }); + + expect(wrapper.classes()).not.toContain('user-can-drag'); + }); + + it('does not add disabled class', () => { + mountComponent(); + expect(wrapper.classes()).not.toContain('is-disabled'); + }); + + it('adds disabled class is disabled is true', () => { + mountComponent({ disabled: true }); + + expect(wrapper.classes()).toContain('is-disabled'); + }); + + describe('mouse events', () => { + it('does not set detail issue if showDetail is false', () => { + mountComponent(); + expect(boardsStore.detail.issue).toEqual({}); + }); + + it('does not set detail issue if link is clicked', () => { + mountComponent(); + findIssueCardInner().find('a').trigger('mouseup'); + + expect(boardsStore.detail.issue).toEqual({}); + }); + + it('does not set detail issue if img is clicked', () => { + mountComponent({ + issue: { + ...list.issues[0], + assignees: [ + new ListAssignee({ + id: 1, + name: 'testing 123', + username: 'test', + avatar: 'test_image', + }), + ], + }, + }); + + findUserAvatarLink().trigger('mouseup'); + + expect(boardsStore.detail.issue).toEqual({}); + }); + + it('does not set detail issue if showDetail is false after mouseup', () => { + mountComponent(); + wrapper.trigger('mouseup'); + + expect(boardsStore.detail.issue).toEqual({}); + }); + + it('sets detail issue to card issue on mouse up', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + mountComponent(); + + wrapper.trigger('mousedown'); + wrapper.trigger('mouseup'); + + expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, false); + expect(boardsStore.detail.list).toEqual(wrapper.vm.list); + }); + + it('resets detail issue to empty if already set', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + const [issue] = list.issues; + boardsStore.detail.issue = issue; + mountComponent(); + + wrapper.trigger('mousedown'); + wrapper.trigger('mouseup'); + + expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', false); + }); + }); + + describe('sidebarHub events', () => { + it('closes all sidebars before showing an issue if no issues are opened', () => { + jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {}); + boardsStore.detail.issue = {}; + mountComponent(); + + // sets conditional so that event is emitted. + wrapper.trigger('mousedown'); + + wrapper.trigger('mouseup'); + + expect(sidebarEventHub.$emit).toHaveBeenCalledWith('sidebar.closeAll'); + }); + + it('it does not closes all sidebars before showing an issue if an issue is opened', () => { + jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {}); + const [issue] = list.issues; + boardsStore.detail.issue = issue; + mountComponent(); + + wrapper.trigger('mousedown'); + + expect(sidebarEventHub.$emit).not.toHaveBeenCalledWith('sidebar.closeAll'); + }); + }); +}); diff --git a/spec/frontend/boards/components/board_card_layout_deprecated_spec.js b/spec/frontend/boards/components/board_card_layout_deprecated_spec.js index 426c5289ba6..9853c9f434f 100644 --- a/spec/frontend/boards/components/board_card_layout_deprecated_spec.js +++ b/spec/frontend/boards/components/board_card_layout_deprecated_spec.js @@ -11,7 +11,7 @@ import '~/boards/models/label'; import '~/boards/models/assignee'; import '~/boards/models/list'; import BoardCardLayout from '~/boards/components/board_card_layout_deprecated.vue'; -import issueCardInner from '~/boards/components/issue_card_inner.vue'; +import issueCardInner from '~/boards/components/issue_card_inner_deprecated.vue'; import { ISSUABLE } from '~/boards/constants'; import boardsVuexStore from '~/boards/stores'; import boardsStore from '~/boards/stores/boards_store'; diff --git a/spec/frontend/boards/components/board_card_layout_spec.js b/spec/frontend/boards/components/board_card_layout_spec.js deleted file mode 100644 index 3fa8714807c..00000000000 --- a/spec/frontend/boards/components/board_card_layout_spec.js +++ /dev/null @@ -1,116 +0,0 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import Vuex from 'vuex'; - -import BoardCardLayout from '~/boards/components/board_card_layout.vue'; -import IssueCardInner from '~/boards/components/issue_card_inner.vue'; -import { ISSUABLE } from '~/boards/constants'; -import defaultState from '~/boards/stores/state'; -import { mockLabelList, mockIssue } from '../mock_data'; - -describe('Board card layout', () => { - let wrapper; - let store; - - const localVue = createLocalVue(); - localVue.use(Vuex); - - const createStore = ({ getters = {}, actions = {} } = {}) => { - store = new Vuex.Store({ - state: defaultState, - actions, - getters, - }); - }; - - // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized - const mountComponent = ({ propsData = {}, provide = {} } = {}) => { - wrapper = shallowMount(BoardCardLayout, { - localVue, - stubs: { - IssueCardInner, - }, - store, - propsData: { - list: mockLabelList, - issue: mockIssue, - disabled: false, - index: 0, - ...propsData, - }, - provide: { - groupId: null, - rootPath: '/', - scopedLabelsAvailable: false, - ...provide, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('mouse events', () => { - it('sets showDetail to true on mousedown', async () => { - createStore(); - mountComponent(); - - wrapper.trigger('mousedown'); - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.showDetail).toBe(true); - }); - - it('sets showDetail to false on mousemove', async () => { - createStore(); - mountComponent(); - wrapper.trigger('mousedown'); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.showDetail).toBe(true); - wrapper.trigger('mousemove'); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.showDetail).toBe(false); - }); - - it("calls 'setActiveId'", async () => { - const setActiveId = jest.fn(); - createStore({ - actions: { - setActiveId, - }, - }); - mountComponent(); - - wrapper.trigger('mouseup'); - await wrapper.vm.$nextTick(); - - expect(setActiveId).toHaveBeenCalledTimes(1); - expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), { - id: mockIssue.id, - sidebarType: ISSUABLE, - }); - }); - - it("calls 'setActiveId' when epic swimlanes is active", async () => { - const setActiveId = jest.fn(); - const isSwimlanesOn = () => true; - createStore({ - getters: { isSwimlanesOn }, - actions: { - setActiveId, - }, - }); - mountComponent(); - - wrapper.trigger('mouseup'); - await wrapper.vm.$nextTick(); - - expect(setActiveId).toHaveBeenCalledTimes(1); - expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), { - id: mockIssue.id, - sidebarType: ISSUABLE, - }); - }); - }); -}); diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index 5f26ae1bb3b..022f8c05e1e 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -1,43 +1,50 @@ -/* global List */ -/* global ListAssignee */ -/* global ListLabel */ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; -import { mount } from '@vue/test-utils'; - -import MockAdapter from 'axios-mock-adapter'; -import waitForPromises from 'helpers/wait_for_promises'; import BoardCard from '~/boards/components/board_card.vue'; -import issueCardInner from '~/boards/components/issue_card_inner.vue'; -import eventHub from '~/boards/eventhub'; -import store from '~/boards/stores'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; - -import sidebarEventHub from '~/sidebar/event_hub'; -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/list'; -import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data'; - -describe('BoardCard', () => { - let wrapper; - let mock; - let list; +import BoardCardInner from '~/boards/components/board_card_inner.vue'; +import { inactiveId } from '~/boards/constants'; +import { mockLabelList, mockIssue } from '../mock_data'; - const findIssueCardInner = () => wrapper.find(issueCardInner); - const findUserAvatarLink = () => wrapper.find(userAvatarLink); +describe('Board card', () => { + let wrapper; + let store; + let mockActions; + + const localVue = createLocalVue(); + localVue.use(Vuex); + + const createStore = ({ initialState = {}, isSwimlanesOn = false } = {}) => { + mockActions = { + toggleBoardItem: jest.fn(), + toggleBoardItemMultiSelection: jest.fn(), + }; + + store = new Vuex.Store({ + state: { + activeId: inactiveId, + selectedBoardItems: [], + ...initialState, + }, + actions: mockActions, + getters: { + isSwimlanesOn: () => isSwimlanesOn, + isEpicBoard: () => false, + }, + }); + }; // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized - const mountComponent = (propsData) => { - wrapper = mount(BoardCard, { + const mountComponent = ({ propsData = {}, provide = {} } = {}) => { + wrapper = shallowMount(BoardCard, { + localVue, stubs: { - issueCardInner, + BoardCardInner, }, store, propsData: { - list, - issue: list.issues[0], + list: mockLabelList, + item: mockIssue, disabled: false, index: 0, ...propsData, @@ -46,174 +53,94 @@ describe('BoardCard', () => { groupId: null, rootPath: '/', scopedLabelsAvailable: false, + ...provide, }, }); }; - const setupData = async () => { - list = new List(listObj); - boardsStore.create(); - boardsStore.detail.issue = {}; - const label1 = new ListLabel({ - id: 3, - title: 'testing 123', - color: '#000cff', - text_color: 'white', - description: 'test', - }); - await waitForPromises(); - - list.issues[0].labels.push(label1); + const selectCard = async () => { + wrapper.trigger('mouseup'); + await wrapper.vm.$nextTick(); }; - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - setMockEndpoints(); - return setupData(); - }); + const multiSelectCard = async () => { + wrapper.trigger('mouseup', { ctrlKey: true }); + await wrapper.vm.$nextTick(); + }; afterEach(() => { wrapper.destroy(); wrapper = null; - list = null; - mock.restore(); - }); - - it('when details issue is empty does not show the element', () => { - mountComponent(); - expect(wrapper.find('[data-testid="board_card"').classes()).not.toContain('is-active'); - }); - - it('when detailIssue is equal to card issue shows the element', () => { - [boardsStore.detail.issue] = list.issues; - mountComponent(); - - expect(wrapper.classes()).toContain('is-active'); - }); - - it('when multiSelect does not contain issue removes multi select class', () => { - mountComponent(); - expect(wrapper.classes()).not.toContain('multi-select'); - }); - - it('when multiSelect contain issue add multi select class', () => { - boardsStore.multiSelect.list = [list.issues[0]]; - mountComponent(); - - expect(wrapper.classes()).toContain('multi-select'); - }); - - it('adds user-can-drag class if not disabled', () => { - mountComponent(); - expect(wrapper.classes()).toContain('user-can-drag'); - }); - - it('does not add user-can-drag class disabled', () => { - mountComponent({ disabled: true }); - - expect(wrapper.classes()).not.toContain('user-can-drag'); - }); - - it('does not add disabled class', () => { - mountComponent(); - expect(wrapper.classes()).not.toContain('is-disabled'); + store = null; }); - it('adds disabled class is disabled is true', () => { - mountComponent({ disabled: true }); - - expect(wrapper.classes()).toContain('is-disabled'); - }); - - describe('mouse events', () => { - it('does not set detail issue if showDetail is false', () => { + describe.each` + isSwimlanesOn + ${true} | ${false} + `('when isSwimlanesOn is $isSwimlanesOn', ({ isSwimlanesOn }) => { + it('should not highlight the card by default', async () => { + createStore({ isSwimlanesOn }); mountComponent(); - expect(boardsStore.detail.issue).toEqual({}); - }); - it('does not set detail issue if link is clicked', () => { - mountComponent(); - findIssueCardInner().find('a').trigger('mouseup'); - - expect(boardsStore.detail.issue).toEqual({}); + expect(wrapper.classes()).not.toContain('is-active'); + expect(wrapper.classes()).not.toContain('multi-select'); }); - it('does not set detail issue if img is clicked', () => { - mountComponent({ - issue: { - ...list.issues[0], - assignees: [ - new ListAssignee({ - id: 1, - name: 'testing 123', - username: 'test', - avatar: 'test_image', - }), - ], + it('should highlight the card with a correct style when selected', async () => { + createStore({ + initialState: { + activeId: mockIssue.id, }, + isSwimlanesOn, }); - - findUserAvatarLink().trigger('mouseup'); - - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('does not set detail issue if showDetail is false after mouseup', () => { - mountComponent(); - wrapper.trigger('mouseup'); - - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('sets detail issue to card issue on mouse up', () => { - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - mountComponent(); - wrapper.trigger('mousedown'); - wrapper.trigger('mouseup'); - - expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, false); - expect(boardsStore.detail.list).toEqual(wrapper.vm.list); + expect(wrapper.classes()).toContain('is-active'); + expect(wrapper.classes()).not.toContain('multi-select'); }); - it('resets detail issue to empty if already set', () => { - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - const [issue] = list.issues; - boardsStore.detail.issue = issue; + it('should highlight the card with a correct style when multi-selected', async () => { + createStore({ + initialState: { + activeId: inactiveId, + selectedBoardItems: [mockIssue], + }, + isSwimlanesOn, + }); mountComponent(); - wrapper.trigger('mousedown'); - wrapper.trigger('mouseup'); - - expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', false); + expect(wrapper.classes()).toContain('multi-select'); + expect(wrapper.classes()).not.toContain('is-active'); }); - }); - - describe('sidebarHub events', () => { - it('closes all sidebars before showing an issue if no issues are opened', () => { - jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {}); - boardsStore.detail.issue = {}; - mountComponent(); - - // sets conditional so that event is emitted. - wrapper.trigger('mousedown'); - wrapper.trigger('mouseup'); + describe('when mouseup event is called on the card', () => { + beforeEach(() => { + createStore({ isSwimlanesOn }); + mountComponent(); + }); - expect(sidebarEventHub.$emit).toHaveBeenCalledWith('sidebar.closeAll'); - }); + describe('when not using multi-select', () => { + it('should call vuex action "toggleBoardItem" with correct parameters', async () => { + await selectCard(); - it('it does not closes all sidebars before showing an issue if an issue is opened', () => { - jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {}); - const [issue] = list.issues; - boardsStore.detail.issue = issue; - mountComponent(); + expect(mockActions.toggleBoardItem).toHaveBeenCalledTimes(1); + expect(mockActions.toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), { + boardItem: mockIssue, + }); + }); + }); - wrapper.trigger('mousedown'); + describe('when using multi-select', () => { + it('should call vuex action "multiSelectBoardItem" with correct parameters', async () => { + await multiSelectCard(); - expect(sidebarEventHub.$emit).not.toHaveBeenCalledWith('sidebar.closeAll'); + expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledTimes(1); + expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledWith( + expect.any(Object), + mockIssue, + ); + }); + }); }); }); }); diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index 858efea99ad..32499bd5480 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -8,6 +8,7 @@ import { formType } from '~/boards/constants'; import createBoardMutation from '~/boards/graphql/board_create.mutation.graphql'; import destroyBoardMutation from '~/boards/graphql/board_destroy.mutation.graphql'; import updateBoardMutation from '~/boards/graphql/board_update.mutation.graphql'; +import { createStore } from '~/boards/stores'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -48,6 +49,13 @@ describe('BoardForm', () => { const findDeleteConfirmation = () => wrapper.find('[data-testid="delete-confirmation-message"]'); const findInput = () => wrapper.find('#board-new-name'); + const store = createStore({ + getters: { + isGroupBoard: () => true, + isProjectBoard: () => false, + }, + }); + const createComponent = (props, data) => { wrapper = shallowMount(BoardForm, { propsData: { ...defaultProps, ...props }, @@ -64,6 +72,7 @@ describe('BoardForm', () => { mutate, }, }, + store, attachTo: document.body, }); }; diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index f30e3792435..d2dfb4148b3 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -1,5 +1,6 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { mockLabelList } from 'jest/boards/mock_data'; import BoardListHeader from '~/boards/components/board_list_header.vue'; @@ -14,6 +15,7 @@ describe('Board List Header Component', () => { let store; const updateListSpy = jest.fn(); + const toggleListCollapsedSpy = jest.fn(); afterEach(() => { wrapper.destroy(); @@ -43,38 +45,39 @@ describe('Board List Header Component', () => { if (withLocalStorage) { localStorage.setItem( - `boards.${boardId}.${listMock.listType}.${listMock.id}.expanded`, - (!collapsed).toString(), + `boards.${boardId}.${listMock.listType}.${listMock.id}.collapsed`, + collapsed.toString(), ); } store = new Vuex.Store({ state: {}, - actions: { updateList: updateListSpy }, - getters: {}, + actions: { updateList: updateListSpy, toggleListCollapsed: toggleListCollapsedSpy }, + getters: { isEpicBoard: () => false }, }); - wrapper = shallowMount(BoardListHeader, { - store, - localVue, - propsData: { - disabled: false, - list: listMock, - }, - provide: { - boardId, - weightFeatureAvailable: false, - currentUserId, - }, - }); + wrapper = extendedWrapper( + shallowMount(BoardListHeader, { + store, + localVue, + propsData: { + disabled: false, + list: listMock, + }, + provide: { + boardId, + weightFeatureAvailable: false, + currentUserId, + }, + }), + ); }; const isCollapsed = () => wrapper.vm.list.collapsed; - const isExpanded = () => !isCollapsed; const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' }); const findTitle = () => wrapper.find('.board-title'); - const findCaret = () => wrapper.find('.board-title-caret'); + const findCaret = () => wrapper.findByTestId('board-title-caret'); describe('Add issue button', () => { const hasNoAddButton = [ListType.closed]; @@ -114,40 +117,29 @@ describe('Board List Header Component', () => { }); describe('expanding / collapsing the column', () => { - it('does not collapse when clicking the header', async () => { + it('should display collapse icon when column is expanded', async () => { createComponent(); - expect(isCollapsed()).toBe(false); - - wrapper.find('[data-testid="board-list-header"]').trigger('click'); + const icon = findCaret(); - await wrapper.vm.$nextTick(); - - expect(isCollapsed()).toBe(false); + expect(icon.props('icon')).toBe('chevron-right'); }); - it('collapses expanded Column when clicking the collapse icon', async () => { - createComponent(); - - expect(isCollapsed()).toBe(false); - - findCaret().vm.$emit('click'); + it('should display expand icon when column is collapsed', async () => { + createComponent({ collapsed: true }); - await wrapper.vm.$nextTick(); + const icon = findCaret(); - expect(isCollapsed()).toBe(true); + expect(icon.props('icon')).toBe('chevron-down'); }); - it('expands collapsed Column when clicking the expand icon', async () => { - createComponent({ collapsed: true }); - - expect(isCollapsed()).toBe(true); + it('should dispatch toggleListCollapse when clicking the collapse icon', async () => { + createComponent(); findCaret().vm.$emit('click'); await wrapper.vm.$nextTick(); - - expect(isCollapsed()).toBe(false); + expect(toggleListCollapsedSpy).toHaveBeenCalledTimes(1); }); it("when logged in it calls list update and doesn't set localStorage", async () => { @@ -157,7 +149,7 @@ describe('Board List Header Component', () => { await wrapper.vm.$nextTick(); expect(updateListSpy).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null); + expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.collapsed`)).toBe(null); }); it("when logged out it doesn't call list update and sets localStorage", async () => { @@ -167,7 +159,7 @@ describe('Board List Header Component', () => { await wrapper.vm.$nextTick(); expect(updateListSpy).not.toHaveBeenCalled(); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded())); + expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.collapsed`)).toBe(String(isCollapsed())); }); }); diff --git a/spec/frontend/boards/components/board_new_issue_spec.js b/spec/frontend/boards/components/board_new_issue_spec.js index ce8c95527e9..737a18294bc 100644 --- a/spec/frontend/boards/components/board_new_issue_spec.js +++ b/spec/frontend/boards/components/board_new_issue_spec.js @@ -2,7 +2,6 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import BoardNewIssue from '~/boards/components/board_new_issue.vue'; -import '~/boards/models/list'; import { mockList, mockGroupProjects } from '../mock_data'; const localVue = createLocalVue(); @@ -31,7 +30,7 @@ describe('Issue boards new issue form', () => { const store = new Vuex.Store({ state: { selectedProject: mockGroupProjects[0] }, actions: { addListNewIssue: addListNewIssuesSpy }, - getters: {}, + getters: { isGroupBoard: () => false, isProjectBoard: () => true }, }); wrapper = shallowMount(BoardNewIssue, { diff --git a/spec/frontend/boards/components/filtered_search_spec.js b/spec/frontend/boards/components/filtered_search_spec.js new file mode 100644 index 00000000000..7f238aa671f --- /dev/null +++ b/spec/frontend/boards/components/filtered_search_spec.js @@ -0,0 +1,65 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import FilteredSearch from '~/boards/components/filtered_search.vue'; +import { createStore } from '~/boards/stores'; +import * as commonUtils from '~/lib/utils/common_utils'; +import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('FilteredSearch', () => { + let wrapper; + let store; + + const createComponent = () => { + wrapper = shallowMount(FilteredSearch, { + localVue, + propsData: { search: '' }, + store, + attachTo: document.body, + }); + }; + + beforeEach(() => { + // this needed for actions call for performSearch + window.gon = { features: {} }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + store = createStore(); + + jest.spyOn(store, 'dispatch'); + + createComponent(); + }); + + it('finds FilteredSearch', () => { + expect(wrapper.find(FilteredSearchBarRoot).exists()).toBe(true); + }); + + describe('when onFilter is emitted', () => { + it('calls performSearch', () => { + wrapper.find(FilteredSearchBarRoot).vm.$emit('onFilter', [{ value: { data: '' } }]); + + expect(store.dispatch).toHaveBeenCalledWith('performSearch'); + }); + + it('calls historyPushState', () => { + commonUtils.historyPushState = jest.fn(); + wrapper + .find(FilteredSearchBarRoot) + .vm.$emit('onFilter', [{ value: { data: 'searchQuery' } }]); + + expect(commonUtils.historyPushState).toHaveBeenCalledWith( + 'http://test.host/?search=searchQuery', + ); + }); + }); + }); +}); diff --git a/spec/frontend/boards/components/issue_count_spec.js b/spec/frontend/boards/components/item_count_spec.js index f1870e9cc9e..45980c36f1c 100644 --- a/spec/frontend/boards/components/issue_count_spec.js +++ b/spec/frontend/boards/components/item_count_spec.js @@ -1,10 +1,10 @@ import { shallowMount } from '@vue/test-utils'; -import IssueCount from '~/boards/components/issue_count.vue'; +import IssueCount from '~/boards/components/item_count.vue'; describe('IssueCount', () => { let vm; let maxIssueCount; - let issuesSize; + let itemsSize; const createComponent = (props) => { vm = shallowMount(IssueCount, { propsData: props }); @@ -12,20 +12,20 @@ describe('IssueCount', () => { afterEach(() => { maxIssueCount = 0; - issuesSize = 0; + itemsSize = 0; if (vm) vm.destroy(); }); describe('when maxIssueCount is zero', () => { beforeEach(() => { - issuesSize = 3; + itemsSize = 3; - createComponent({ maxIssueCount: 0, issuesSize }); + createComponent({ maxIssueCount: 0, itemsSize }); }); it('contains issueSize in the template', () => { - expect(vm.find('.js-issue-size').text()).toEqual(String(issuesSize)); + expect(vm.find('[data-testid="board-items-count"]').text()).toEqual(String(itemsSize)); }); it('does not contains maxIssueCount in the template', () => { @@ -36,9 +36,9 @@ describe('IssueCount', () => { describe('when maxIssueCount is greater than zero', () => { beforeEach(() => { maxIssueCount = 2; - issuesSize = 1; + itemsSize = 1; - createComponent({ maxIssueCount, issuesSize }); + createComponent({ maxIssueCount, itemsSize }); }); afterEach(() => { @@ -46,7 +46,7 @@ describe('IssueCount', () => { }); it('contains issueSize in the template', () => { - expect(vm.find('.js-issue-size').text()).toEqual(String(issuesSize)); + expect(vm.find('[data-testid="board-items-count"]').text()).toEqual(String(itemsSize)); }); it('contains maxIssueCount in the template', () => { @@ -60,10 +60,10 @@ describe('IssueCount', () => { describe('when issueSize is greater than maxIssueCount', () => { beforeEach(() => { - issuesSize = 3; + itemsSize = 3; maxIssueCount = 2; - createComponent({ maxIssueCount, issuesSize }); + createComponent({ maxIssueCount, itemsSize }); }); afterEach(() => { @@ -71,7 +71,7 @@ describe('IssueCount', () => { }); it('contains issueSize in the template', () => { - expect(vm.find('.js-issue-size').text()).toEqual(String(issuesSize)); + expect(vm.find('[data-testid="board-items-count"]').text()).toEqual(String(itemsSize)); }); it('contains maxIssueCount in the template', () => { @@ -79,7 +79,7 @@ describe('IssueCount', () => { }); it('has text-danger class', () => { - expect(vm.find('.text-danger').text()).toEqual(String(issuesSize)); + expect(vm.find('.text-danger').text()).toEqual(String(itemsSize)); }); }); }); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js index 7838b5a0b2f..8fd178a0856 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js @@ -24,7 +24,7 @@ describe('~/boards/components/sidebar/board_sidebar_due_date.vue', () => { const createWrapper = ({ dueDate = null } = {}) => { store = createStore(); - store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, dueDate } }; + store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, dueDate } }; store.state.activeId = TEST_ISSUE.id; wrapper = shallowMount(BoardSidebarDueDate, { @@ -61,7 +61,7 @@ describe('~/boards/components/sidebar/board_sidebar_due_date.vue', () => { createWrapper(); jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { - store.state.issues[TEST_ISSUE.id].dueDate = TEST_DUE_DATE; + store.state.boardItems[TEST_ISSUE.id].dueDate = TEST_DUE_DATE; }); findDatePicker().vm.$emit('input', TEST_PARSED_DATE); await wrapper.vm.$nextTick(); @@ -86,7 +86,7 @@ describe('~/boards/components/sidebar/board_sidebar_due_date.vue', () => { createWrapper(); jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { - store.state.issues[TEST_ISSUE.id].dueDate = null; + store.state.boardItems[TEST_ISSUE.id].dueDate = null; }); findDatePicker().vm.$emit('clear'); await wrapper.vm.$nextTick(); @@ -104,7 +104,7 @@ describe('~/boards/components/sidebar/board_sidebar_due_date.vue', () => { createWrapper({ dueDate: TEST_DUE_DATE }); jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { - store.state.issues[TEST_ISSUE.id].dueDate = null; + store.state.boardItems[TEST_ISSUE.id].dueDate = null; }); findResetButton().vm.$emit('click'); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js index bc7df1c76c6..723d0345f76 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js @@ -34,7 +34,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { const createWrapper = (issue = TEST_ISSUE_A) => { store = createStore(); - store.state.issues = { [issue.id]: { ...issue } }; + store.state.boardItems = { [issue.id]: { ...issue } }; store.dispatch('setActiveId', { id: issue.id }); wrapper = shallowMount(BoardSidebarIssueTitle, { @@ -74,7 +74,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { createWrapper(); jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => { - store.state.issues[TEST_ISSUE_A.id].title = TEST_TITLE; + store.state.boardItems[TEST_ISSUE_A.id].title = TEST_TITLE; }); findFormInput().vm.$emit('input', TEST_TITLE); findForm().vm.$emit('submit', { preventDefault: () => {} }); @@ -147,7 +147,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => { createWrapper(TEST_ISSUE_B); jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => { - store.state.issues[TEST_ISSUE_B.id].title = TEST_TITLE; + store.state.boardItems[TEST_ISSUE_B.id].title = TEST_TITLE; }); findFormInput().vm.$emit('input', TEST_TITLE); findCancelButton().vm.$emit('click'); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js index 12b873ba7d8..98ac211238c 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js @@ -25,7 +25,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { const createWrapper = ({ labels = [] } = {}) => { store = createStore(); - store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, labels } }; + store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, labels } }; store.state.activeId = TEST_ISSUE.id; wrapper = shallowMount(BoardSidebarLabelsSelect, { @@ -66,7 +66,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => TEST_LABELS); findLabelsSelect().vm.$emit('updateSelectedLabels', TEST_LABELS_PAYLOAD); - store.state.issues[TEST_ISSUE.id].labels = TEST_LABELS; + store.state.boardItems[TEST_ISSUE.id].labels = TEST_LABELS; await wrapper.vm.$nextTick(); }); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js index 8820ec7ae63..8706424a296 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js @@ -22,7 +22,7 @@ describe('~/boards/components/sidebar/board_sidebar_milestone_select.vue', () => const createWrapper = ({ milestone = null, loading = false } = {}) => { store = createStore(); - store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, milestone } }; + store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, milestone } }; store.state.activeId = TEST_ISSUE.id; wrapper = shallowMount(BoardSidebarMilestoneSelect, { @@ -113,7 +113,7 @@ describe('~/boards/components/sidebar/board_sidebar_milestone_select.vue', () => createWrapper(); jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => { - store.state.issues[TEST_ISSUE.id].milestone = TEST_MILESTONE; + store.state.boardItems[TEST_ISSUE.id].milestone = TEST_MILESTONE; }); findDropdownItem().vm.$emit('click'); await wrapper.vm.$nextTick(); @@ -137,7 +137,7 @@ describe('~/boards/components/sidebar/board_sidebar_milestone_select.vue', () => createWrapper({ milestone: TEST_MILESTONE }); jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => { - store.state.issues[TEST_ISSUE.id].milestone = null; + store.state.boardItems[TEST_ISSUE.id].milestone = null; }); findUnsetMilestoneItem().vm.$emit('click'); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js index 3e6b0be0267..cfd7f32b2cc 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js @@ -22,7 +22,7 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () = const createComponent = (activeIssue = { ...mockActiveIssue }) => { store = createStore(); - store.state.issues = { [activeIssue.id]: activeIssue }; + store.state.boardItems = { [activeIssue.id]: activeIssue }; store.state.activeId = activeIssue.id; wrapper = mount(BoardSidebarSubscription, { @@ -45,6 +45,12 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () = expect(findNotificationHeader().text()).toBe('Notifications'); }); + it('renders toggle with label', () => { + createComponent(); + + expect(findToggle().props('label')).toBe(BoardSidebarSubscription.i18n.header.title); + }); + it('renders toggle as "off" when currently not subscribed', () => { createComponent(); diff --git a/spec/frontend/boards/components/sidebar/remove_issue_spec.js b/spec/frontend/boards/components/sidebar/remove_issue_spec.js deleted file mode 100644 index 1f740c10106..00000000000 --- a/spec/frontend/boards/components/sidebar/remove_issue_spec.js +++ /dev/null @@ -1,28 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; - -import RemoveIssue from '~/boards/components/sidebar/remove_issue.vue'; - -describe('boards sidebar remove issue', () => { - let wrapper; - - const findButton = () => wrapper.find(GlButton); - - const createComponent = (propsData) => { - wrapper = shallowMount(RemoveIssue, { - propsData: { - issue: {}, - list: {}, - ...propsData, - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - it('renders remove button', () => { - expect(findButton().exists()).toBe(true); - }); -}); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index e106b9235d6..500240d00fc 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -351,6 +351,7 @@ export const issues = { [mockIssue4.id]: mockIssue4, }; +// The response from group project REST API export const mockRawGroupProjects = [ { id: 0, @@ -366,17 +367,34 @@ export const mockRawGroupProjects = [ }, ]; -export const mockGroupProjects = [ - { - id: 0, - name: 'Example Project', - nameWithNamespace: 'Awesome Group / Example Project', - fullPath: 'awesome-group/example-project', - }, - { - id: 1, - name: 'Foobar Project', - nameWithNamespace: 'Awesome Group / Foobar Project', - fullPath: 'awesome-group/foobar-project', - }, +// The response from GraphQL endpoint +export const mockGroupProject1 = { + id: 0, + name: 'Example Project', + nameWithNamespace: 'Awesome Group / Example Project', + fullPath: 'awesome-group/example-project', + archived: false, +}; + +export const mockGroupProject2 = { + id: 1, + name: 'Foobar Project', + nameWithNamespace: 'Awesome Group / Foobar Project', + fullPath: 'awesome-group/foobar-project', + archived: false, +}; + +export const mockArchivedGroupProject = { + id: 2, + name: 'Archived Project', + nameWithNamespace: 'Awesome Group / Archived Project', + fullPath: 'awesome-group/archived-project', + archived: true, +}; + +export const mockGroupProjects = [mockGroupProject1, mockGroupProject2]; + +export const mockActiveGroupProjects = [ + { ...mockGroupProject1, archived: false }, + { ...mockGroupProject2, archived: false }, ]; diff --git a/spec/frontend/boards/project_select_deprecated_spec.js b/spec/frontend/boards/project_select_deprecated_spec.js index 9042c4bf9ba..37f519ef5b9 100644 --- a/spec/frontend/boards/project_select_deprecated_spec.js +++ b/spec/frontend/boards/project_select_deprecated_spec.js @@ -27,6 +27,7 @@ const mockDefaultFetchOptions = { with_shared: false, include_subgroups: true, order_by: 'similarity', + archived: false, }; const itemsPerPage = 20; diff --git a/spec/frontend/boards/project_select_spec.js b/spec/frontend/boards/project_select_spec.js index aa71952c42b..de823094630 100644 --- a/spec/frontend/boards/project_select_spec.js +++ b/spec/frontend/boards/project_select_spec.js @@ -1,30 +1,17 @@ import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; -import { createLocalVue, mount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import ProjectSelect from '~/boards/components/project_select.vue'; import defaultState from '~/boards/stores/state'; -import { mockList, mockGroupProjects } from './mock_data'; +import { mockList, mockActiveGroupProjects } from './mock_data'; -const localVue = createLocalVue(); -localVue.use(Vuex); - -const actions = { - fetchGroupProjects: jest.fn(), - setSelectedProject: jest.fn(), -}; - -const createStore = (state = defaultState) => { - return new Vuex.Store({ - state, - actions, - }); -}; - -const mockProjectsList1 = mockGroupProjects.slice(0, 1); +const mockProjectsList1 = mockActiveGroupProjects.slice(0, 1); describe('ProjectSelect component', () => { let wrapper; + let store; const findLabel = () => wrapper.find("[data-testid='header-label']"); const findGlDropdown = () => wrapper.find(GlDropdown); @@ -36,20 +23,37 @@ describe('ProjectSelect component', () => { const findInMenuLoadingIcon = () => wrapper.find("[data-testid='dropdown-text-loading-icon']"); const findEmptySearchMessage = () => wrapper.find("[data-testid='empty-result-message']"); - const createWrapper = (state = {}) => { - const store = createStore({ - groupProjects: [], - groupProjectsFlags: { - isLoading: false, - pageInfo: { - hasNextPage: false, + const createStore = ({ state, activeGroupProjects }) => { + Vue.use(Vuex); + + store = new Vuex.Store({ + state: { + defaultState, + groupProjectsFlags: { + isLoading: false, + pageInfo: { + hasNextPage: false, + }, }, + ...state, + }, + actions: { + fetchGroupProjects: jest.fn(), + setSelectedProject: jest.fn(), }, - ...state, + getters: { + activeGroupProjects: () => activeGroupProjects, + }, + }); + }; + + const createWrapper = ({ state = {}, activeGroupProjects = [] } = {}) => { + createStore({ + state, + activeGroupProjects, }); wrapper = mount(ProjectSelect, { - localVue, propsData: { list: mockList, }, @@ -93,7 +97,7 @@ describe('ProjectSelect component', () => { describe('when dropdown menu is open', () => { describe('by default', () => { beforeEach(() => { - createWrapper({ groupProjects: mockGroupProjects }); + createWrapper({ activeGroupProjects: mockActiveGroupProjects }); }); it('shows GlSearchBoxByType with default attributes', () => { @@ -128,7 +132,7 @@ describe('ProjectSelect component', () => { describe('when a project is selected', () => { beforeEach(() => { - createWrapper({ groupProjects: mockProjectsList1 }); + createWrapper({ activeGroupProjects: mockProjectsList1 }); findFirstGlDropdownItem().find('button').trigger('click'); }); @@ -142,7 +146,7 @@ describe('ProjectSelect component', () => { describe('when projects are loading', () => { beforeEach(() => { - createWrapper({ groupProjectsFlags: { isLoading: true } }); + createWrapper({ state: { groupProjectsFlags: { isLoading: true } } }); }); it('displays and hides gl-loading-icon while and after fetching data', () => { diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 32d0e7ae886..69d2c8977fb 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -5,7 +5,7 @@ import { formatBoardLists, formatIssueInput, } from '~/boards/boards_util'; -import { inactiveId } from '~/boards/constants'; +import { inactiveId, ISSUABLE } from '~/boards/constants'; import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql'; import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'; import issueMoveListMutation from '~/boards/graphql/issue_move_list.mutation.graphql'; @@ -112,6 +112,15 @@ describe('setActiveId', () => { }); describe('fetchLists', () => { + it('should dispatch fetchIssueLists action', () => { + testAction({ + action: actions.fetchLists, + expectedActions: [{ type: 'fetchIssueLists' }], + }); + }); +}); + +describe('fetchIssueLists', () => { const state = { fullPath: 'gitlab-org', boardId: '1', @@ -138,7 +147,7 @@ describe('fetchLists', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); testAction( - actions.fetchLists, + actions.fetchIssueLists, {}, state, [ @@ -152,6 +161,23 @@ describe('fetchLists', () => { ); }); + it('should commit mutations RECEIVE_BOARD_LISTS_FAILURE on failure', (done) => { + jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject()); + + testAction( + actions.fetchIssueLists, + {}, + state, + [ + { + type: types.RECEIVE_BOARD_LISTS_FAILURE, + }, + ], + [], + done, + ); + }); + it('dispatch createList action when backlog list does not exist and is not hidden', (done) => { queryResponse = { data: { @@ -168,7 +194,7 @@ describe('fetchLists', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); testAction( - actions.fetchLists, + actions.fetchIssueLists, {}, state, [ @@ -184,6 +210,16 @@ describe('fetchLists', () => { }); describe('createList', () => { + it('should dispatch createIssueList action', () => { + testAction({ + action: actions.createList, + payload: { backlog: true }, + expectedActions: [{ type: 'createIssueList', payload: { backlog: true } }], + }); + }); +}); + +describe('createIssueList', () => { let commit; let dispatch; let getters; @@ -223,7 +259,7 @@ describe('createList', () => { }), ); - await actions.createList({ getters, state, commit, dispatch }, { backlog: true }); + await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true }); expect(dispatch).toHaveBeenCalledWith('addList', backlogList); }); @@ -245,7 +281,7 @@ describe('createList', () => { }, }); - await actions.createList({ getters, state, commit, dispatch }, { labelId: '4' }); + await actions.createIssueList({ getters, state, commit, dispatch }, { labelId: '4' }); expect(dispatch).toHaveBeenCalledWith('addList', list); expect(dispatch).toHaveBeenCalledWith('highlightList', list.id); @@ -257,15 +293,15 @@ describe('createList', () => { data: { boardListCreate: { list: {}, - errors: [{ foo: 'bar' }], + errors: ['foo'], }, }, }), ); - await actions.createList({ getters, state, commit, dispatch }, { backlog: true }); + await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true }); - expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE); + expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE, 'foo'); }); it('highlights list and does not re-query if it already exists', async () => { @@ -280,7 +316,7 @@ describe('createList', () => { getListByLabelId: jest.fn().mockReturnValue(existingList), }; - await actions.createList({ getters, state, commit, dispatch }, { backlog: true }); + await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true }); expect(dispatch).toHaveBeenCalledWith('highlightList', existingList.id); expect(dispatch).toHaveBeenCalledTimes(1); @@ -301,11 +337,15 @@ describe('fetchLabels', () => { }; jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); - await testAction({ - action: actions.fetchLabels, - state: { boardType: 'group' }, - expectedMutations: [{ type: types.RECEIVE_LABELS_SUCCESS, payload: labels }], - }); + const commit = jest.fn(); + const getters = { + shouldUseGraphQL: () => true, + }; + const state = { boardType: 'group' }; + + await actions.fetchLabels({ getters, state, commit }); + + expect(commit).toHaveBeenCalledWith(types.RECEIVE_LABELS_SUCCESS, labels); }); }); @@ -412,6 +452,22 @@ describe('updateList', () => { }); }); +describe('toggleListCollapsed', () => { + it('should commit TOGGLE_LIST_COLLAPSED mutation', async () => { + const payload = { listId: 'gid://gitlab/List/1', collapsed: true }; + await testAction({ + action: actions.toggleListCollapsed, + payload, + expectedMutations: [ + { + type: types.TOGGLE_LIST_COLLAPSED, + payload, + }, + ], + }); + }); +}); + describe('removeList', () => { let state; const list = mockLists[0]; @@ -490,7 +546,7 @@ describe('removeList', () => { }); }); -describe('fetchIssuesForList', () => { +describe('fetchItemsForList', () => { const listId = mockLists[0].id; const state = { @@ -533,21 +589,21 @@ describe('fetchIssuesForList', () => { [listId]: pageInfo, }; - it('should commit mutations REQUEST_ISSUES_FOR_LIST and RECEIVE_ISSUES_FOR_LIST_SUCCESS on success', (done) => { + it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_SUCCESS on success', (done) => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); testAction( - actions.fetchIssuesForList, + actions.fetchItemsForList, { listId }, state, [ { - type: types.REQUEST_ISSUES_FOR_LIST, + type: types.REQUEST_ITEMS_FOR_LIST, payload: { listId, fetchNext: false }, }, { - type: types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, - payload: { listIssues: formattedIssues, listPageInfo, listId }, + type: types.RECEIVE_ITEMS_FOR_LIST_SUCCESS, + payload: { listItems: formattedIssues, listPageInfo, listId }, }, ], [], @@ -555,19 +611,19 @@ describe('fetchIssuesForList', () => { ); }); - it('should commit mutations REQUEST_ISSUES_FOR_LIST and RECEIVE_ISSUES_FOR_LIST_FAILURE on failure', (done) => { + it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_FAILURE on failure', (done) => { jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject()); testAction( - actions.fetchIssuesForList, + actions.fetchItemsForList, { listId }, state, [ { - type: types.REQUEST_ISSUES_FOR_LIST, + type: types.REQUEST_ITEMS_FOR_LIST, payload: { listId, fetchNext: false }, }, - { type: types.RECEIVE_ISSUES_FOR_LIST_FAILURE, payload: listId }, + { type: types.RECEIVE_ITEMS_FOR_LIST_FAILURE, payload: listId }, ], [], done, @@ -581,6 +637,15 @@ describe('resetIssues', () => { }); }); +describe('moveItem', () => { + it('should dispatch moveIssue action', () => { + testAction({ + action: actions.moveItem, + expectedActions: [{ type: 'moveIssue' }], + }); + }); +}); + describe('moveIssue', () => { const listIssues = { 'gid://gitlab/List/1': [436, 437], @@ -598,8 +663,8 @@ describe('moveIssue', () => { boardType: 'group', disabled: false, boardLists: mockLists, - issuesByListId: listIssues, - issues, + boardItemsByListId: listIssues, + boardItems: issues, }; it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_SUCCESS mutation when successful', (done) => { @@ -615,9 +680,9 @@ describe('moveIssue', () => { testAction( actions.moveIssue, { - issueId: '436', - issueIid: mockIssue.iid, - issuePath: mockIssue.referencePath, + itemId: '436', + itemIid: mockIssue.iid, + itemPath: mockIssue.referencePath, fromListId: 'gid://gitlab/List/1', toListId: 'gid://gitlab/List/2', }, @@ -666,9 +731,9 @@ describe('moveIssue', () => { actions.moveIssue( { state, commit: () => {} }, { - issueId: mockIssue.id, - issueIid: mockIssue.iid, - issuePath: mockIssue.referencePath, + itemId: mockIssue.id, + itemIid: mockIssue.iid, + itemPath: mockIssue.referencePath, fromListId: 'gid://gitlab/List/1', toListId: 'gid://gitlab/List/2', }, @@ -690,9 +755,9 @@ describe('moveIssue', () => { testAction( actions.moveIssue, { - issueId: '436', - issueIid: mockIssue.iid, - issuePath: mockIssue.referencePath, + itemId: '436', + itemIid: mockIssue.iid, + itemPath: mockIssue.referencePath, fromListId: 'gid://gitlab/List/1', toListId: 'gid://gitlab/List/2', }, @@ -879,7 +944,7 @@ describe('addListIssue', () => { }); describe('setActiveIssueLabels', () => { - const state = { issues: { [mockIssue.id]: mockIssue } }; + const state = { boardItems: { [mockIssue.id]: mockIssue } }; const getters = { activeIssue: mockIssue }; const testLabelIds = labels.map((label) => label.id); const input = { @@ -924,7 +989,7 @@ describe('setActiveIssueLabels', () => { }); describe('setActiveIssueDueDate', () => { - const state = { issues: { [mockIssue.id]: mockIssue } }; + const state = { boardItems: { [mockIssue.id]: mockIssue } }; const getters = { activeIssue: mockIssue }; const testDueDate = '2020-02-20'; const input = { @@ -975,7 +1040,7 @@ describe('setActiveIssueDueDate', () => { }); describe('setActiveIssueSubscribed', () => { - const state = { issues: { [mockActiveIssue.id]: mockActiveIssue } }; + const state = { boardItems: { [mockActiveIssue.id]: mockActiveIssue } }; const getters = { activeIssue: mockActiveIssue }; const subscribedState = true; const input = { @@ -1026,7 +1091,7 @@ describe('setActiveIssueSubscribed', () => { }); describe('setActiveIssueMilestone', () => { - const state = { issues: { [mockIssue.id]: mockIssue } }; + const state = { boardItems: { [mockIssue.id]: mockIssue } }; const getters = { activeIssue: mockIssue }; const testMilestone = { ...mockMilestone, @@ -1080,7 +1145,7 @@ describe('setActiveIssueMilestone', () => { }); describe('setActiveIssueTitle', () => { - const state = { issues: { [mockIssue.id]: mockIssue } }; + const state = { boardItems: { [mockIssue.id]: mockIssue } }; const getters = { activeIssue: mockIssue }; const testTitle = 'Test Title'; const input = { @@ -1220,6 +1285,7 @@ describe('setSelectedProject', () => { describe('toggleBoardItemMultiSelection', () => { const boardItem = mockIssue; + const boardItem2 = mockIssue2; it('should commit mutation ADD_BOARD_ITEM_TO_SELECTION if item is not on selection state', () => { testAction( @@ -1250,6 +1316,66 @@ describe('toggleBoardItemMultiSelection', () => { [], ); }); + + it('should additionally commit mutation ADD_BOARD_ITEM_TO_SELECTION for active issue and dispatch unsetActiveId', () => { + testAction( + actions.toggleBoardItemMultiSelection, + boardItem2, + { activeId: mockActiveIssue.id, activeIssue: mockActiveIssue, selectedBoardItems: [] }, + [ + { + type: types.ADD_BOARD_ITEM_TO_SELECTION, + payload: mockActiveIssue, + }, + { + type: types.ADD_BOARD_ITEM_TO_SELECTION, + payload: boardItem2, + }, + ], + [{ type: 'unsetActiveId' }], + ); + }); +}); + +describe('resetBoardItemMultiSelection', () => { + it('should commit mutation RESET_BOARD_ITEM_SELECTION', () => { + testAction({ + action: actions.resetBoardItemMultiSelection, + state: { selectedBoardItems: [mockIssue] }, + expectedMutations: [ + { + type: types.RESET_BOARD_ITEM_SELECTION, + }, + ], + }); + }); +}); + +describe('toggleBoardItem', () => { + it('should dispatch resetBoardItemMultiSelection and unsetActiveId when boardItem is the active item', () => { + testAction({ + action: actions.toggleBoardItem, + payload: { boardItem: mockIssue }, + state: { + activeId: mockIssue.id, + }, + expectedActions: [{ type: 'resetBoardItemMultiSelection' }, { type: 'unsetActiveId' }], + }); + }); + + it('should dispatch resetBoardItemMultiSelection and setActiveId when boardItem is not the active item', () => { + testAction({ + action: actions.toggleBoardItem, + payload: { boardItem: mockIssue }, + state: { + activeId: inactiveId, + }, + expectedActions: [ + { type: 'resetBoardItemMultiSelection' }, + { type: 'setActiveId', payload: { id: mockIssue.id, sidebarType: ISSUABLE } }, + ], + }); + }); }); describe('fetchBacklog', () => { diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js index d5a19bf613f..32d73d861bc 100644 --- a/spec/frontend/boards/stores/getters_spec.js +++ b/spec/frontend/boards/stores/getters_spec.js @@ -7,9 +7,47 @@ import { mockIssuesByListId, issues, mockLists, + mockGroupProject1, + mockArchivedGroupProject, } from '../mock_data'; describe('Boards - Getters', () => { + describe('isGroupBoard', () => { + it('returns true when boardType on state is group', () => { + const state = { + boardType: 'group', + }; + + expect(getters.isGroupBoard(state)).toBe(true); + }); + + it('returns false when boardType on state is not group', () => { + const state = { + boardType: 'project', + }; + + expect(getters.isGroupBoard(state)).toBe(false); + }); + }); + + describe('isProjectBoard', () => { + it('returns true when boardType on state is project', () => { + const state = { + boardType: 'project', + }; + + expect(getters.isProjectBoard(state)).toBe(true); + }); + + it('returns false when boardType on state is not project', () => { + const state = { + boardType: 'group', + }; + + expect(getters.isProjectBoard(state)).toBe(false); + }); + }); + describe('isSidebarOpen', () => { it('returns true when activeId is not equal to 0', () => { const state = { @@ -38,15 +76,15 @@ describe('Boards - Getters', () => { }); }); - describe('getIssueById', () => { - const state = { issues: { 1: 'issue' } }; + describe('getBoardItemById', () => { + const state = { boardItems: { 1: 'issue' } }; it.each` id | expected ${'1'} | ${'issue'} ${''} | ${{}} `('returns $expected when $id is passed to state', ({ id, expected }) => { - expect(getters.getIssueById(state)(id)).toEqual(expected); + expect(getters.getBoardItemById(state)(id)).toEqual(expected); }); }); @@ -56,7 +94,7 @@ describe('Boards - Getters', () => { ${'1'} | ${'issue'} ${''} | ${{}} `('returns $expected when $id is passed to state', ({ id, expected }) => { - const state = { issues: { 1: 'issue' }, activeId: id }; + const state = { boardItems: { 1: 'issue' }, activeId: id }; expect(getters.activeIssue(state)).toEqual(expected); }); @@ -94,17 +132,18 @@ describe('Boards - Getters', () => { }); }); - describe('getIssuesByList', () => { + describe('getBoardItemsByList', () => { const boardsState = { - issuesByListId: mockIssuesByListId, - issues, + boardItemsByListId: mockIssuesByListId, + boardItems: issues, }; it('returns issues for a given listId', () => { - const getIssueById = (issueId) => [mockIssue, mockIssue2].find(({ id }) => id === issueId); + const getBoardItemById = (issueId) => + [mockIssue, mockIssue2].find(({ id }) => id === issueId); - expect(getters.getIssuesByList(boardsState, { getIssueById })('gid://gitlab/List/2')).toEqual( - mockIssues, - ); + expect( + getters.getBoardItemsByList(boardsState, { getBoardItemById })('gid://gitlab/List/2'), + ).toEqual(mockIssues); }); }); @@ -128,4 +167,14 @@ describe('Boards - Getters', () => { expect(getters.getListByTitle(boardsState)('To Do')).toEqual(mockLists[1]); }); }); + + describe('activeGroupProjects', () => { + const state = { + groupProjects: [mockGroupProject1, mockArchivedGroupProject], + }; + + it('returns only returns non-archived group projects', () => { + expect(getters.activeGroupProjects(state)).toEqual([mockGroupProject1]); + }); + }); }); diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index 9423f2ed583..33897cc0250 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -1,3 +1,4 @@ +import { issuableTypes } from '~/boards/constants'; import * as types from '~/boards/stores/mutation_types'; import mutations from '~/boards/stores/mutations'; import defaultState from '~/boards/stores/state'; @@ -37,6 +38,7 @@ describe('Board Store Mutations', () => { const boardConfig = { milestoneTitle: 'Milestone 1', }; + const issuableType = issuableTypes.issue; mutations[types.SET_INITIAL_BOARD_DATA](state, { boardId, @@ -44,6 +46,7 @@ describe('Board Store Mutations', () => { boardType, disabled, boardConfig, + issuableType, }); expect(state.boardId).toEqual(boardId); @@ -51,6 +54,7 @@ describe('Board Store Mutations', () => { expect(state.boardType).toEqual(boardType); expect(state.disabled).toEqual(disabled); expect(state.boardConfig).toEqual(boardConfig); + expect(state.issuableType).toEqual(issuableType); }); }); @@ -106,11 +110,31 @@ describe('Board Store Mutations', () => { }); }); + describe('RECEIVE_LABELS_REQUEST', () => { + it('sets labelsLoading on state', () => { + mutations.RECEIVE_LABELS_REQUEST(state); + + expect(state.labelsLoading).toEqual(true); + }); + }); + describe('RECEIVE_LABELS_SUCCESS', () => { it('sets labels on state', () => { mutations.RECEIVE_LABELS_SUCCESS(state, labels); expect(state.labels).toEqual(labels); + expect(state.labelsLoading).toEqual(false); + }); + }); + + describe('RECEIVE_LABELS_FAILURE', () => { + it('sets error message', () => { + mutations.RECEIVE_LABELS_FAILURE(state); + + expect(state.error).toEqual( + 'An error occurred while fetching labels. Please reload the page.', + ); + expect(state.labelsLoading).toEqual(false); }); }); @@ -179,6 +203,24 @@ describe('Board Store Mutations', () => { }); }); + describe('TOGGLE_LIST_COLLAPSED', () => { + it('updates collapsed attribute of list in boardLists state', () => { + const listId = 'gid://gitlab/List/1'; + state = { + ...state, + boardLists: { + [listId]: mockLists[0], + }, + }; + + expect(state.boardLists[listId].collapsed).toEqual(false); + + mutations.TOGGLE_LIST_COLLAPSED(state, { listId, collapsed: true }); + + expect(state.boardLists[listId].collapsed).toEqual(true); + }); + }); + describe('REMOVE_LIST', () => { it('removes list from boardLists', () => { const [list, secondList] = mockLists; @@ -219,24 +261,24 @@ describe('Board Store Mutations', () => { }); describe('RESET_ISSUES', () => { - it('should remove issues from issuesByListId state', () => { - const issuesByListId = { + it('should remove issues from boardItemsByListId state', () => { + const boardItemsByListId = { 'gid://gitlab/List/1': [mockIssue.id], }; state = { ...state, - issuesByListId, + boardItemsByListId, }; mutations[types.RESET_ISSUES](state); - expect(state.issuesByListId).toEqual({ 'gid://gitlab/List/1': [] }); + expect(state.boardItemsByListId).toEqual({ 'gid://gitlab/List/1': [] }); }); }); - describe('RECEIVE_ISSUES_FOR_LIST_SUCCESS', () => { - it('updates issuesByListId and issues on state', () => { + describe('RECEIVE_ITEMS_FOR_LIST_SUCCESS', () => { + it('updates boardItemsByListId and issues on state', () => { const listIssues = { 'gid://gitlab/List/1': [mockIssue.id], }; @@ -246,10 +288,10 @@ describe('Board Store Mutations', () => { state = { ...state, - issuesByListId: { + boardItemsByListId: { 'gid://gitlab/List/1': [], }, - issues: {}, + boardItems: {}, boardLists: initialBoardListsState, }; @@ -260,18 +302,18 @@ describe('Board Store Mutations', () => { }, }; - mutations.RECEIVE_ISSUES_FOR_LIST_SUCCESS(state, { - listIssues: { listData: listIssues, issues }, + mutations.RECEIVE_ITEMS_FOR_LIST_SUCCESS(state, { + listItems: { listData: listIssues, boardItems: issues }, listPageInfo, listId: 'gid://gitlab/List/1', }); - expect(state.issuesByListId).toEqual(listIssues); - expect(state.issues).toEqual(issues); + expect(state.boardItemsByListId).toEqual(listIssues); + expect(state.boardItems).toEqual(issues); }); }); - describe('RECEIVE_ISSUES_FOR_LIST_FAILURE', () => { + describe('RECEIVE_ITEMS_FOR_LIST_FAILURE', () => { it('sets error message', () => { state = { ...state, @@ -281,7 +323,7 @@ describe('Board Store Mutations', () => { const listId = 'gid://gitlab/List/1'; - mutations.RECEIVE_ISSUES_FOR_LIST_FAILURE(state, listId); + mutations.RECEIVE_ITEMS_FOR_LIST_FAILURE(state, listId); expect(state.error).toEqual( 'An error occurred while fetching the board issues. Please reload the page.', @@ -303,7 +345,7 @@ describe('Board Store Mutations', () => { state = { ...state, error: undefined, - issues: { + boardItems: { ...issue, }, }; @@ -317,7 +359,7 @@ describe('Board Store Mutations', () => { value, }); - expect(state.issues[issueId]).toEqual({ ...issue[issueId], id: '2' }); + expect(state.boardItems[issueId]).toEqual({ ...issue[issueId], id: '2' }); }); }); @@ -343,7 +385,7 @@ describe('Board Store Mutations', () => { }); describe('MOVE_ISSUE', () => { - it('updates issuesByListId, moving issue between lists', () => { + it('updates boardItemsByListId, moving issue between lists', () => { const listIssues = { 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], 'gid://gitlab/List/2': [], @@ -356,9 +398,9 @@ describe('Board Store Mutations', () => { state = { ...state, - issuesByListId: listIssues, + boardItemsByListId: listIssues, boardLists: initialBoardListsState, - issues, + boardItems: issues, }; mutations.MOVE_ISSUE(state, { @@ -372,7 +414,7 @@ describe('Board Store Mutations', () => { 'gid://gitlab/List/2': [mockIssue2.id], }; - expect(state.issuesByListId).toEqual(updatedListIssues); + expect(state.boardItemsByListId).toEqual(updatedListIssues); }); }); @@ -384,19 +426,19 @@ describe('Board Store Mutations', () => { state = { ...state, - issues, + boardItems: issues, }; mutations.MOVE_ISSUE_SUCCESS(state, { issue: rawIssue, }); - expect(state.issues).toEqual({ 436: { ...mockIssue, id: 436 } }); + expect(state.boardItems).toEqual({ 436: { ...mockIssue, id: 436 } }); }); }); describe('MOVE_ISSUE_FAILURE', () => { - it('updates issuesByListId, reverting moving issue between lists, and sets error message', () => { + it('updates boardItemsByListId, reverting moving issue between lists, and sets error message', () => { const listIssues = { 'gid://gitlab/List/1': [mockIssue.id], 'gid://gitlab/List/2': [mockIssue2.id], @@ -404,7 +446,7 @@ describe('Board Store Mutations', () => { state = { ...state, - issuesByListId: listIssues, + boardItemsByListId: listIssues, boardLists: initialBoardListsState, }; @@ -420,7 +462,7 @@ describe('Board Store Mutations', () => { 'gid://gitlab/List/2': [], }; - expect(state.issuesByListId).toEqual(updatedListIssues); + expect(state.boardItemsByListId).toEqual(updatedListIssues); expect(state.error).toEqual('An error occurred while moving the issue. Please try again.'); }); }); @@ -446,7 +488,7 @@ describe('Board Store Mutations', () => { }); describe('ADD_ISSUE_TO_LIST', () => { - it('adds issue to issues state and issue id in list in issuesByListId', () => { + it('adds issue to issues state and issue id in list in boardItemsByListId', () => { const listIssues = { 'gid://gitlab/List/1': [mockIssue.id], }; @@ -456,8 +498,8 @@ describe('Board Store Mutations', () => { state = { ...state, - issuesByListId: listIssues, - issues, + boardItemsByListId: listIssues, + boardItems: issues, boardLists: initialBoardListsState, }; @@ -465,14 +507,14 @@ describe('Board Store Mutations', () => { mutations.ADD_ISSUE_TO_LIST(state, { list: mockLists[0], issue: mockIssue2 }); - expect(state.issuesByListId['gid://gitlab/List/1']).toContain(mockIssue2.id); - expect(state.issues[mockIssue2.id]).toEqual(mockIssue2); + expect(state.boardItemsByListId['gid://gitlab/List/1']).toContain(mockIssue2.id); + expect(state.boardItems[mockIssue2.id]).toEqual(mockIssue2); expect(state.boardLists['gid://gitlab/List/1'].issuesCount).toBe(2); }); }); describe('ADD_ISSUE_TO_LIST_FAILURE', () => { - it('removes issue id from list in issuesByListId and sets error message', () => { + it('removes issue id from list in boardItemsByListId and sets error message', () => { const listIssues = { 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], }; @@ -483,20 +525,20 @@ describe('Board Store Mutations', () => { state = { ...state, - issuesByListId: listIssues, - issues, + boardItemsByListId: listIssues, + boardItems: issues, boardLists: initialBoardListsState, }; mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issueId: mockIssue2.id }); - expect(state.issuesByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id); + expect(state.boardItemsByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id); expect(state.error).toBe('An error occurred while creating the issue. Please try again.'); }); }); describe('REMOVE_ISSUE_FROM_LIST', () => { - it('removes issue id from list in issuesByListId and deletes issue from state', () => { + it('removes issue id from list in boardItemsByListId and deletes issue from state', () => { const listIssues = { 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], }; @@ -507,15 +549,15 @@ describe('Board Store Mutations', () => { state = { ...state, - issuesByListId: listIssues, - issues, + boardItemsByListId: listIssues, + boardItems: issues, boardLists: initialBoardListsState, }; mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issueId: mockIssue2.id }); - expect(state.issuesByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id); - expect(state.issues).not.toContain(mockIssue2); + expect(state.boardItemsByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id); + expect(state.boardItems).not.toContain(mockIssue2); }); }); @@ -607,14 +649,21 @@ describe('Board Store Mutations', () => { describe('REMOVE_BOARD_ITEM_FROM_SELECTION', () => { it('Should remove boardItem to selectedBoardItems state', () => { - state = { - ...state, - selectedBoardItems: [mockIssue], - }; + state.selectedBoardItems = [mockIssue]; mutations[types.REMOVE_BOARD_ITEM_FROM_SELECTION](state, mockIssue); expect(state.selectedBoardItems).toEqual([]); }); }); + + describe('RESET_BOARD_ITEM_SELECTION', () => { + it('Should reset selectedBoardItems state', () => { + state.selectedBoardItems = [mockIssue]; + + mutations[types.RESET_BOARD_ITEM_SELECTION](state, mockIssue); + + expect(state.selectedBoardItems).toEqual([]); + }); + }); }); diff --git a/spec/frontend/bootstrap_linked_tabs_spec.js b/spec/frontend/bootstrap_linked_tabs_spec.js index 2d8939e6480..30fb140bc69 100644 --- a/spec/frontend/bootstrap_linked_tabs_spec.js +++ b/spec/frontend/bootstrap_linked_tabs_spec.js @@ -1,8 +1,6 @@ import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs'; describe('Linked Tabs', () => { - preloadFixtures('static/linked_tabs.html'); - beforeEach(() => { loadFixtures('static/linked_tabs.html'); }); diff --git a/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js b/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js new file mode 100644 index 00000000000..df81b78d010 --- /dev/null +++ b/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js @@ -0,0 +1,119 @@ +import MockAdapter from 'axios-mock-adapter'; + +import { registerCaptchaModalInterceptor } from '~/captcha/captcha_modal_axios_interceptor'; +import { waitForCaptchaToBeSolved } from '~/captcha/wait_for_captcha_to_be_solved'; +import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; + +jest.mock('~/captcha/wait_for_captcha_to_be_solved'); + +describe('registerCaptchaModalInterceptor', () => { + const SPAM_LOG_ID = 'SPAM_LOG_ID'; + const CAPTCHA_SITE_KEY = 'CAPTCHA_SITE_KEY'; + const CAPTCHA_SUCCESS = 'CAPTCHA_SUCCESS'; + const CAPTCHA_RESPONSE = 'CAPTCHA_RESPONSE'; + const AXIOS_RESPONSE = { text: 'AXIOS_RESPONSE' }; + const NEEDS_CAPTCHA_RESPONSE = { + needs_captcha_response: true, + captcha_site_key: CAPTCHA_SITE_KEY, + spam_log_id: SPAM_LOG_ID, + }; + + const unsupportedMethods = ['delete', 'get', 'head', 'options']; + const supportedMethods = ['patch', 'post', 'put']; + + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onAny('/no-captcha').reply(200, AXIOS_RESPONSE); + mock.onAny('/error').reply(404, AXIOS_RESPONSE); + mock.onAny('/captcha').reply((config) => { + if (!supportedMethods.includes(config.method)) { + return [httpStatusCodes.METHOD_NOT_ALLOWED, { method: config.method }]; + } + + try { + const { captcha_response, spam_log_id, ...rest } = JSON.parse(config.data); + // eslint-disable-next-line babel/camelcase + if (captcha_response === CAPTCHA_RESPONSE && spam_log_id === SPAM_LOG_ID) { + return [httpStatusCodes.OK, { ...rest, method: config.method, CAPTCHA_SUCCESS }]; + } + } catch (e) { + return [httpStatusCodes.BAD_REQUEST, { method: config.method }]; + } + + return [httpStatusCodes.CONFLICT, NEEDS_CAPTCHA_RESPONSE]; + }); + + axios.interceptors.response.handlers = []; + registerCaptchaModalInterceptor(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe.each([...supportedMethods, ...unsupportedMethods])('For HTTP method %s', (method) => { + it('successful requests are passed through', async () => { + const { data, status } = await axios[method]('/no-captcha'); + + expect(status).toEqual(httpStatusCodes.OK); + expect(data).toEqual(AXIOS_RESPONSE); + expect(mock.history[method]).toHaveLength(1); + }); + + it('error requests without needs_captcha_response_errors are passed through', async () => { + await expect(() => axios[method]('/error')).rejects.toThrow( + expect.objectContaining({ + response: expect.objectContaining({ + status: httpStatusCodes.NOT_FOUND, + data: AXIOS_RESPONSE, + }), + }), + ); + expect(mock.history[method]).toHaveLength(1); + }); + }); + + describe.each(supportedMethods)('For HTTP method %s', (method) => { + describe('error requests with needs_captcha_response_errors', () => { + const submittedData = { ID: 12345 }; + + it('re-submits request if captcha was solved correctly', async () => { + waitForCaptchaToBeSolved.mockResolvedValue(CAPTCHA_RESPONSE); + const { data: returnedData } = await axios[method]('/captcha', submittedData); + + expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); + + expect(returnedData).toEqual({ ...submittedData, CAPTCHA_SUCCESS, method }); + expect(mock.history[method]).toHaveLength(2); + }); + + it('does not re-submit request if captcha was not solved', async () => { + const error = new Error('Captcha not solved'); + waitForCaptchaToBeSolved.mockRejectedValue(error); + await expect(() => axios[method]('/captcha', submittedData)).rejects.toThrow(error); + + expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); + expect(mock.history[method]).toHaveLength(1); + }); + }); + }); + + describe.each(unsupportedMethods)('For HTTP method %s', (method) => { + it('ignores captcha response', async () => { + await expect(() => axios[method]('/captcha')).rejects.toThrow( + expect.objectContaining({ + response: expect.objectContaining({ + status: httpStatusCodes.METHOD_NOT_ALLOWED, + data: { method }, + }), + }), + ); + + expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); + expect(mock.history[method]).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/captcha/wait_for_captcha_to_be_solved_spec.js b/spec/frontend/captcha/wait_for_captcha_to_be_solved_spec.js new file mode 100644 index 00000000000..08d031a4fa7 --- /dev/null +++ b/spec/frontend/captcha/wait_for_captcha_to_be_solved_spec.js @@ -0,0 +1,56 @@ +import CaptchaModal from '~/captcha/captcha_modal.vue'; +import { waitForCaptchaToBeSolved } from '~/captcha/wait_for_captcha_to_be_solved'; + +jest.mock('~/captcha/captcha_modal.vue', () => ({ + mounted: jest.fn(), + render(h) { + return h('div', { attrs: { id: 'mock-modal' } }); + }, +})); + +describe('waitForCaptchaToBeSolved', () => { + const response = 'CAPTCHA_RESPONSE'; + + const findModal = () => document.querySelector('#mock-modal'); + + it('opens a modal, resolves with captcha response on success', async () => { + CaptchaModal.mounted.mockImplementationOnce(function mounted() { + requestAnimationFrame(() => { + this.$emit('receivedCaptchaResponse', response); + this.$emit('hidden'); + }); + }); + + expect(findModal()).toBeNull(); + + const promise = waitForCaptchaToBeSolved('FOO'); + + expect(findModal()).not.toBeNull(); + + const result = await promise; + expect(result).toEqual(response); + + expect(findModal()).toBeNull(); + expect(document.body.innerHTML).toEqual(''); + }); + + it("opens a modal, rejects with error in case the captcha isn't solved", async () => { + CaptchaModal.mounted.mockImplementationOnce(function mounted() { + requestAnimationFrame(() => { + this.$emit('receivedCaptchaResponse', null); + this.$emit('hidden'); + }); + }); + + expect(findModal()).toBeNull(); + + const promise = waitForCaptchaToBeSolved('FOO'); + + expect(findModal()).not.toBeNull(); + + await expect(promise).rejects.toThrow(/You must solve the CAPTCHA in order to submit/); + + expect(findModal()).toBeNull(); + expect(document.body.innerHTML).toEqual(''); + }); +}); diff --git a/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js index ad1bdec1735..1bca21b1d57 100644 --- a/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js +++ b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js @@ -4,9 +4,6 @@ import VariableList from '~/ci_variable_list/ci_variable_list'; const HIDE_CLASS = 'hide'; describe('VariableList', () => { - preloadFixtures('pipeline_schedules/edit.html'); - preloadFixtures('pipeline_schedules/edit_with_variables.html'); - let $wrapper; let variableList; diff --git a/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js index 4982b68fa81..eee1362440d 100644 --- a/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js +++ b/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js @@ -2,8 +2,6 @@ import $ from 'jquery'; import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list'; describe('NativeFormVariableList', () => { - preloadFixtures('pipeline_schedules/edit.html'); - let $wrapper; beforeEach(() => { diff --git a/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js b/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js index 75c6e8e4540..5c5ea102f12 100644 --- a/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue'; @@ -10,6 +10,9 @@ describe('Ci environments dropdown', () => { let wrapper; let store; + const enterSearchTerm = (value) => + wrapper.find('[data-testid="ci-environment-search"]').setValue(value); + const createComponent = (term) => { store = new Vuex.Store({ getters: { @@ -24,11 +27,12 @@ describe('Ci environments dropdown', () => { value: term, }, }); + enterSearchTerm(term); }; - const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem); - const findDropdownItemByIndex = (index) => wrapper.findAll(GlDropdownItem).at(index); - const findActiveIconByIndex = (index) => findDropdownItemByIndex(index).find(GlIcon); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemByIndex = (index) => wrapper.findAllComponents(GlDropdownItem).at(index); + const findActiveIconByIndex = (index) => findDropdownItemByIndex(index).findComponent(GlIcon); afterEach(() => { wrapper.destroy(); @@ -68,8 +72,9 @@ describe('Ci environments dropdown', () => { }); describe('Environments found', () => { - beforeEach(() => { + beforeEach(async () => { createComponent('prod'); + await wrapper.vm.$nextTick(); }); it('renders only the environment searched for', () => { @@ -84,21 +89,29 @@ describe('Ci environments dropdown', () => { }); it('should not display empty results message', () => { - expect(wrapper.find({ ref: 'noMatchingResults' }).exists()).toBe(false); + expect(wrapper.findComponent({ ref: 'noMatchingResults' }).exists()).toBe(false); }); it('should display active checkmark if active', () => { expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(false); }); + it('should clear the search term when showing the dropdown', () => { + wrapper.findComponent(GlDropdown).trigger('click'); + + expect(wrapper.find('[data-testid="ci-environment-search"]').text()).toBe(''); + }); + describe('Custom events', () => { it('should emit selectEnvironment if an environment is clicked', () => { findDropdownItemByIndex(0).vm.$emit('click'); expect(wrapper.emitted('selectEnvironment')).toEqual([['prod']]); }); - it('should emit createClicked if an environment is clicked', () => { + it('should emit createClicked if an environment is clicked', async () => { createComponent('newscope'); + + await wrapper.vm.$nextTick(); findDropdownItemByIndex(1).vm.$emit('click'); expect(wrapper.emitted('createClicked')).toEqual([['newscope']]); }); diff --git a/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js b/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js index fd6d9854868..f83a350a27c 100644 --- a/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js +++ b/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js @@ -59,6 +59,12 @@ describe('IngressModsecuritySettings', () => { }); }); + it('renders toggle with label', () => { + expect(findModSecurityToggle().props('label')).toBe( + IngressModsecuritySettings.i18n.modSecurityEnabled, + ); + }); + it('renders save and cancel buttons', () => { expect(findSaveButton().exists()).toBe(true); expect(findCancelButton().exists()).toBe(true); diff --git a/spec/frontend/clusters/forms/components/integration_form_spec.js b/spec/frontend/clusters/forms/components/integration_form_spec.js index 0323245244d..c5cec4c4fdb 100644 --- a/spec/frontend/clusters/forms/components/integration_form_spec.js +++ b/spec/frontend/clusters/forms/components/integration_form_spec.js @@ -45,8 +45,12 @@ describe('ClusterIntegrationForm', () => { beforeEach(() => createWrapper()); it('enables toggle if editable is true', () => { - expect(findGlToggle().props('disabled')).toBe(false); + expect(findGlToggle().props()).toMatchObject({ + disabled: false, + label: IntegrationForm.i18n.toggleLabel, + }); }); + it('sets the envScope to default', () => { expect(wrapper.find('[id="cluster_environment_scope"]').attributes('value')).toBe('*'); }); diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index f398d7a0965..941a3adb625 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -4,12 +4,12 @@ import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlTable, } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Clusters from '~/clusters_list/components/clusters.vue'; import ClusterStore from '~/clusters_list/store'; import axios from '~/lib/utils/axios_utils'; -import * as Sentry from '~/sentry/wrapper'; import { apiData } from '../mock_data'; describe('Clusters', () => { diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js index 00b998166aa..b2ef3c2138a 100644 --- a/spec/frontend/clusters_list/store/actions_spec.js +++ b/spec/frontend/clusters_list/store/actions_spec.js @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/browser'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -7,7 +8,6 @@ import * as types from '~/clusters_list/store/mutation_types'; import { deprecatedCreateFlash as flashError } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import Poll from '~/lib/utils/poll'; -import * as Sentry from '~/sentry/wrapper'; import { apiData } from '../mock_data'; jest.mock('~/flash.js'); diff --git a/spec/frontend/collapsed_sidebar_todo_spec.js b/spec/frontend/collapsed_sidebar_todo_spec.js index ef53cc9e103..7c659822672 100644 --- a/spec/frontend/collapsed_sidebar_todo_spec.js +++ b/spec/frontend/collapsed_sidebar_todo_spec.js @@ -14,9 +14,6 @@ describe('Issuable right sidebar collapsed todo toggle', () => { const jsonFixtureName = 'todos/todos.json'; let mock; - preloadFixtures(fixtureName); - preloadFixtures(jsonFixtureName); - beforeEach(() => { const todoData = getJSONFixture(jsonFixtureName); new Sidebar(); diff --git a/spec/frontend/commit/pipelines/pipelines_spec.js b/spec/frontend/commit/pipelines/pipelines_spec.js index f8bdd00f5da..bbe02daa24b 100644 --- a/spec/frontend/commit/pipelines/pipelines_spec.js +++ b/spec/frontend/commit/pipelines/pipelines_spec.js @@ -13,14 +13,10 @@ describe('Pipelines table in Commits and Merge requests', () => { let vm; const props = { endpoint: 'endpoint.json', - helpPagePath: 'foo', emptyStateSvgPath: 'foo', errorStateSvgPath: 'foo', - autoDevopsHelpPath: 'foo', }; - preloadFixtures(jsonFixtureName); - const findRunPipelineBtn = () => vm.$el.querySelector('[data-testid="run_pipeline_button"]'); const findRunPipelineBtnMobile = () => vm.$el.querySelector('[data-testid="run_pipeline_button_mobile"]'); @@ -275,7 +271,6 @@ describe('Pipelines table in Commits and Merge requests', () => { setImmediate(() => { expect(vm.$el.querySelector('.js-pipelines-error-state')).toBeDefined(); expect(vm.$el.querySelector('.realtime-loading')).toBe(null); - expect(vm.$el.querySelector('.js-empty-state')).toBe(null); expect(vm.$el.querySelector('.ci-table')).toBe(null); done(); }); diff --git a/spec/frontend/create_item_dropdown_spec.js b/spec/frontend/create_item_dropdown_spec.js index 7314eb5eee8..56c09cd731e 100644 --- a/spec/frontend/create_item_dropdown_spec.js +++ b/spec/frontend/create_item_dropdown_spec.js @@ -20,8 +20,6 @@ const DROPDOWN_ITEM_DATA = [ ]; describe('CreateItemDropdown', () => { - preloadFixtures('static/create_item_dropdown.html'); - let $wrapperEl; let createItemDropdown; diff --git a/spec/frontend/deprecated_jquery_dropdown_spec.js b/spec/frontend/deprecated_jquery_dropdown_spec.js index 6070532a1bf..7858f88f8c3 100644 --- a/spec/frontend/deprecated_jquery_dropdown_spec.js +++ b/spec/frontend/deprecated_jquery_dropdown_spec.js @@ -10,8 +10,6 @@ jest.mock('~/lib/utils/url_utility', () => ({ })); describe('deprecatedJQueryDropdown', () => { - preloadFixtures('static/deprecated_jquery_dropdown.html'); - const NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item'; const SEARCH_INPUT_SELECTOR = '.dropdown-input-field'; diff --git a/spec/frontend/design_management/components/delete_button_spec.js b/spec/frontend/design_management/components/delete_button_spec.js index 8f7d8e0b214..f5a841d35b8 100644 --- a/spec/frontend/design_management/components/delete_button_spec.js +++ b/spec/frontend/design_management/components/delete_button_spec.js @@ -36,7 +36,7 @@ describe('Batch delete button component', () => { expect(findButton().attributes('disabled')).toBeTruthy(); }); - it('emits `deleteSelectedDesigns` event on modal ok click', () => { + it('emits `delete-selected-designs` event on modal ok click', () => { createComponent(); findButton().vm.$emit('click'); return wrapper.vm @@ -46,7 +46,7 @@ describe('Batch delete button component', () => { return wrapper.vm.$nextTick(); }) .then(() => { - expect(wrapper.emitted().deleteSelectedDesigns).toBeTruthy(); + expect(wrapper.emitted('delete-selected-designs')).toBeTruthy(); }); }); diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js index 92e188f4bcc..efadb9b717d 100644 --- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -93,7 +93,7 @@ describe('Design discussions component', () => { }); it('does not render a checkbox in reply form', () => { - findReplyPlaceholder().vm.$emit('onClick'); + findReplyPlaceholder().vm.$emit('focus'); return wrapper.vm.$nextTick().then(() => { expect(findResolveCheckbox().exists()).toBe(false); @@ -124,7 +124,7 @@ describe('Design discussions component', () => { }); it('renders a checkbox with Resolve thread text in reply form', () => { - findReplyPlaceholder().vm.$emit('onClick'); + findReplyPlaceholder().vm.$emit('focus'); wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id }); return wrapper.vm.$nextTick().then(() => { @@ -193,7 +193,7 @@ describe('Design discussions component', () => { }); it('renders a checkbox with Unresolve thread text in reply form', () => { - findReplyPlaceholder().vm.$emit('onClick'); + findReplyPlaceholder().vm.$emit('focus'); wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id }); return wrapper.vm.$nextTick().then(() => { @@ -205,7 +205,7 @@ describe('Design discussions component', () => { it('hides reply placeholder and opens form on placeholder click', () => { createComponent(); - findReplyPlaceholder().vm.$emit('onClick'); + findReplyPlaceholder().vm.$emit('focus'); wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id }); return wrapper.vm.$nextTick().then(() => { @@ -307,7 +307,7 @@ describe('Design discussions component', () => { it('emits openForm event on opening the form', () => { createComponent(); - findReplyPlaceholder().vm.$emit('onClick'); + findReplyPlaceholder().vm.$emit('focus'); expect(wrapper.emitted('open-form')).toBeTruthy(); }); diff --git a/spec/frontend/design_management/components/list/item_spec.js b/spec/frontend/design_management/components/list/item_spec.js index caf0f8bb5bc..58636ece91e 100644 --- a/spec/frontend/design_management/components/list/item_spec.js +++ b/spec/frontend/design_management/components/list/item_spec.js @@ -8,7 +8,7 @@ const localVue = createLocalVue(); localVue.use(VueRouter); const router = new VueRouter(); -// Referenced from: doc/api/graphql/reference/gitlab_schema.graphql:DesignVersionEvent +// Referenced from: gitlab_schema.graphql:DesignVersionEvent const DESIGN_VERSION_EVENT = { CREATION: 'CREATION', DELETION: 'DELETION', diff --git a/spec/frontend/design_management/components/toolbar/index_spec.js b/spec/frontend/design_management/components/toolbar/index_spec.js index 44c865d976d..009ffe57744 100644 --- a/spec/frontend/design_management/components/toolbar/index_spec.js +++ b/spec/frontend/design_management/components/toolbar/index_spec.js @@ -106,11 +106,11 @@ describe('Design management toolbar component', () => { }); }); - it('emits `delete` event on deleteButton `deleteSelectedDesigns` event', () => { + it('emits `delete` event on deleteButton `delete-selected-designs` event', () => { createComponent(); return wrapper.vm.$nextTick().then(() => { - wrapper.find(DeleteButton).vm.$emit('deleteSelectedDesigns'); + wrapper.find(DeleteButton).vm.$emit('delete-selected-designs'); expect(wrapper.emitted().delete).toBeTruthy(); }); }); diff --git a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap index 2f857247303..904bb2022ca 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap @@ -19,7 +19,7 @@ exports[`Design management upload button component renders inverted upload desig <input accept="image/*" - class="hide" + class="gl-display-none" multiple="multiple" name="design_file" type="file" @@ -44,7 +44,7 @@ exports[`Design management upload button component renders upload design button <input accept="image/*" - class="hide" + class="gl-display-none" multiple="multiple" name="design_file" type="file" diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 4f162ca8e7f..95cb1ac943c 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -97,7 +97,7 @@ describe('Design management index page', () => { let moveDesignHandler; const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox'); - const findSelectAllButton = () => wrapper.find('.js-select-all'); + const findSelectAllButton = () => wrapper.find('[data-testid="select-all-designs-button"'); const findToolbar = () => wrapper.find('.qa-selector-toolbar'); const findDesignCollectionIsCopying = () => wrapper.find('[data-testid="design-collection-is-copying"'); @@ -542,7 +542,9 @@ describe('Design management index page', () => { await nextTick(); expect(findDeleteButton().exists()).toBe(true); expect(findSelectAllButton().text()).toBe('Deselect all'); - findDeleteButton().vm.$emit('deleteSelectedDesigns'); + + findDeleteButton().vm.$emit('delete-selected-designs'); + const [{ variables }] = mutate.mock.calls[0]; expect(variables.filenames).toStrictEqual([mockDesigns[0].filename, mockDesigns[1].filename]); }); diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index d2b5338a0cc..34547238c23 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -14,9 +14,6 @@ import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue'; import NoChanges from '~/diffs/components/no_changes.vue'; import TreeList from '~/diffs/components/tree_list.vue'; -import { EVT_VIEW_FILE_BY_FILE } from '~/diffs/constants'; - -import eventHub from '~/diffs/event_hub'; import axios from '~/lib/utils/axios_utils'; import * as urlUtils from '~/lib/utils/url_utility'; import createDiffsStore from '../create_diffs_store'; @@ -699,24 +696,5 @@ describe('diffs/components/app', () => { }, ); }); - - describe('control via event stream', () => { - it.each` - setting - ${true} - ${false} - `( - 'triggers the action with the new fileByFile setting - $setting - when the event with that setting is received', - async ({ setting }) => { - createComponent(); - await nextTick(); - - eventHub.$emit(EVT_VIEW_FILE_BY_FILE, { setting }); - await nextTick(); - - expect(store.state.diffs.viewDiffsFileByFile).toBe(setting); - }, - ); - }); }); }); diff --git a/spec/frontend/diffs/components/inline_diff_table_row_spec.js b/spec/frontend/diffs/components/inline_diff_table_row_spec.js index 7e6f75ad6f8..28b3055b58c 100644 --- a/spec/frontend/diffs/components/inline_diff_table_row_spec.js +++ b/spec/frontend/diffs/components/inline_diff_table_row_spec.js @@ -215,14 +215,14 @@ describe('InlineDiffTableRow', () => { const TEST_LINE_NUMBER = 1; describe.each` - lineProps | findLineNumber | expectedHref | expectedClickArg - ${{ line_code: TEST_LINE_CODE, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${`#${TEST_LINE_CODE}`} | ${TEST_LINE_CODE} - ${{ line_code: undefined, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${undefined} - ${{ line_code: undefined, left: { line_code: TEST_LINE_CODE }, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${TEST_LINE_CODE} - ${{ line_code: undefined, right: { line_code: TEST_LINE_CODE }, new_line: TEST_LINE_NUMBER }} | ${findLineNumberNew} | ${'#'} | ${TEST_LINE_CODE} + lineProps | findLineNumber | expectedHref | expectedClickArg | expectedQaSelector + ${{ line_code: TEST_LINE_CODE, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${`#${TEST_LINE_CODE}`} | ${TEST_LINE_CODE} | ${undefined} + ${{ line_code: undefined, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${undefined} | ${undefined} + ${{ line_code: undefined, left: { line_code: TEST_LINE_CODE }, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${TEST_LINE_CODE} | ${undefined} + ${{ line_code: undefined, right: { line_code: TEST_LINE_CODE }, new_line: TEST_LINE_NUMBER }} | ${findLineNumberNew} | ${'#'} | ${TEST_LINE_CODE} | ${'new_diff_line_link'} `( 'with line ($lineProps)', - ({ lineProps, findLineNumber, expectedHref, expectedClickArg }) => { + ({ lineProps, findLineNumber, expectedHref, expectedClickArg, expectedQaSelector }) => { beforeEach(() => { jest.spyOn(store, 'dispatch').mockImplementation(); createComponent({ @@ -235,6 +235,7 @@ describe('InlineDiffTableRow', () => { expect(findLineNumber().attributes()).toEqual({ href: expectedHref, 'data-linenumber': TEST_LINE_NUMBER.toString(), + 'data-qa-selector': expectedQaSelector, }); }); diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js index 99fa83b64f1..feac88cb802 100644 --- a/spec/frontend/diffs/components/settings_dropdown_spec.js +++ b/spec/frontend/diffs/components/settings_dropdown_spec.js @@ -1,82 +1,66 @@ -import { mount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; +import { mount } from '@vue/test-utils'; + +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; + import SettingsDropdown from '~/diffs/components/settings_dropdown.vue'; -import { - EVT_VIEW_FILE_BY_FILE, - PARALLEL_DIFF_VIEW_TYPE, - INLINE_DIFF_VIEW_TYPE, -} from '~/diffs/constants'; +import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constants'; import eventHub from '~/diffs/event_hub'; -import diffModule from '~/diffs/store/modules'; -const localVue = createLocalVue(); -localVue.use(Vuex); +import createDiffsStore from '../create_diffs_store'; describe('Diff settings dropdown component', () => { let wrapper; let vm; - let actions; + let store; function createComponent(extendStore = () => {}) { - const store = new Vuex.Store({ - modules: { - diffs: { - namespaced: true, - actions, - state: diffModule().state, - getters: diffModule().getters, - }, - }, - }); + store = createDiffsStore(); extendStore(store); - wrapper = mount(SettingsDropdown, { - localVue, - store, - }); + wrapper = extendedWrapper( + mount(SettingsDropdown, { + store, + }), + ); vm = wrapper.vm; } function getFileByFileCheckbox(vueWrapper) { - return vueWrapper.find('[data-testid="file-by-file"]'); + return vueWrapper.findByTestId('file-by-file'); + } + + function setup({ storeUpdater } = {}) { + createComponent(storeUpdater); + jest.spyOn(store, 'dispatch').mockImplementation(() => {}); } beforeEach(() => { - actions = { - setInlineDiffViewType: jest.fn(), - setParallelDiffViewType: jest.fn(), - setRenderTreeList: jest.fn(), - setShowWhitespace: jest.fn(), - }; + setup(); }); afterEach(() => { + store.dispatch.mockRestore(); wrapper.destroy(); }); describe('tree view buttons', () => { it('list view button dispatches setRenderTreeList with false', () => { - createComponent(); - wrapper.find('.js-list-view').trigger('click'); - expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), false); + expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', false); }); it('tree view button dispatches setRenderTreeList with true', () => { - createComponent(); - wrapper.find('.js-tree-view').trigger('click'); - expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), true); + expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', true); }); it('sets list button as selected when renderTreeList is false', () => { - createComponent((store) => { - Object.assign(store.state.diffs, { - renderTreeList: false, - }); + setup({ + storeUpdater: (origStore) => + Object.assign(origStore.state.diffs, { renderTreeList: false }), }); expect(wrapper.find('.js-list-view').classes('selected')).toBe(true); @@ -84,10 +68,8 @@ describe('Diff settings dropdown component', () => { }); it('sets tree button as selected when renderTreeList is true', () => { - createComponent((store) => { - Object.assign(store.state.diffs, { - renderTreeList: true, - }); + setup({ + storeUpdater: (origStore) => Object.assign(origStore.state.diffs, { renderTreeList: true }), }); expect(wrapper.find('.js-list-view').classes('selected')).toBe(false); @@ -97,10 +79,9 @@ describe('Diff settings dropdown component', () => { describe('compare changes', () => { it('sets inline button as selected', () => { - createComponent((store) => { - Object.assign(store.state.diffs, { - diffViewType: INLINE_DIFF_VIEW_TYPE, - }); + setup({ + storeUpdater: (origStore) => + Object.assign(origStore.state.diffs, { diffViewType: INLINE_DIFF_VIEW_TYPE }), }); expect(wrapper.find('.js-inline-diff-button').classes('selected')).toBe(true); @@ -108,10 +89,9 @@ describe('Diff settings dropdown component', () => { }); it('sets parallel button as selected', () => { - createComponent((store) => { - Object.assign(store.state.diffs, { - diffViewType: PARALLEL_DIFF_VIEW_TYPE, - }); + setup({ + storeUpdater: (origStore) => + Object.assign(origStore.state.diffs, { diffViewType: PARALLEL_DIFF_VIEW_TYPE }), }); expect(wrapper.find('.js-inline-diff-button').classes('selected')).toBe(false); @@ -119,53 +99,49 @@ describe('Diff settings dropdown component', () => { }); it('calls setInlineDiffViewType when clicking inline button', () => { - createComponent(); - wrapper.find('.js-inline-diff-button').trigger('click'); - expect(actions.setInlineDiffViewType).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith('diffs/setInlineDiffViewType', expect.anything()); }); it('calls setParallelDiffViewType when clicking parallel button', () => { - createComponent(); - wrapper.find('.js-parallel-diff-button').trigger('click'); - expect(actions.setParallelDiffViewType).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith( + 'diffs/setParallelDiffViewType', + expect.anything(), + ); }); }); describe('whitespace toggle', () => { it('does not set as checked when showWhitespace is false', () => { - createComponent((store) => { - Object.assign(store.state.diffs, { - showWhitespace: false, - }); + setup({ + storeUpdater: (origStore) => + Object.assign(origStore.state.diffs, { showWhitespace: false }), }); - expect(wrapper.find('#show-whitespace').element.checked).toBe(false); + expect(wrapper.findByTestId('show-whitespace').element.checked).toBe(false); }); it('sets as checked when showWhitespace is true', () => { - createComponent((store) => { - Object.assign(store.state.diffs, { - showWhitespace: true, - }); + setup({ + storeUpdater: (origStore) => Object.assign(origStore.state.diffs, { showWhitespace: true }), }); - expect(wrapper.find('#show-whitespace').element.checked).toBe(true); + expect(wrapper.findByTestId('show-whitespace').element.checked).toBe(true); }); - it('calls setShowWhitespace on change', () => { - createComponent(); + it('calls setShowWhitespace on change', async () => { + const checkbox = wrapper.findByTestId('show-whitespace'); + const { checked } = checkbox.element; - const checkbox = wrapper.find('#show-whitespace'); + checkbox.trigger('click'); - checkbox.element.checked = true; - checkbox.trigger('change'); + await vm.$nextTick(); - expect(actions.setShowWhitespace).toHaveBeenCalledWith(expect.anything(), { - showWhitespace: true, + expect(store.dispatch).toHaveBeenCalledWith('diffs/setShowWhitespace', { + showWhitespace: !checked, pushState: true, }); }); @@ -182,39 +158,35 @@ describe('Diff settings dropdown component', () => { ${false} | ${false} `( 'sets the checkbox to { checked: $checked } if the fileByFile setting is $fileByFile', - async ({ fileByFile, checked }) => { - createComponent((store) => { - Object.assign(store.state.diffs, { - viewDiffsFileByFile: fileByFile, - }); + ({ fileByFile, checked }) => { + setup({ + storeUpdater: (origStore) => + Object.assign(origStore.state.diffs, { viewDiffsFileByFile: fileByFile }), }); - await vm.$nextTick(); - expect(getFileByFileCheckbox(wrapper).element.checked).toBe(checked); }, ); it.each` - start | emit + start | setting ${true} | ${false} ${false} | ${true} `( - 'when the file by file setting starts as $start, toggling the checkbox should emit an event set to $emit', - async ({ start, emit }) => { - createComponent((store) => { - Object.assign(store.state.diffs, { - viewDiffsFileByFile: start, - }); + 'when the file by file setting starts as $start, toggling the checkbox should call setFileByFile with $setting', + async ({ start, setting }) => { + setup({ + storeUpdater: (origStore) => + Object.assign(origStore.state.diffs, { viewDiffsFileByFile: start }), }); - await vm.$nextTick(); - getFileByFileCheckbox(wrapper).trigger('click'); await vm.$nextTick(); - expect(eventHub.$emit).toHaveBeenCalledWith(EVT_VIEW_FILE_BY_FILE, { setting: emit }); + expect(store.dispatch).toHaveBeenCalledWith('diffs/setFileByFile', { + fileByFile: setting, + }); }, ); }); diff --git a/spec/frontend/diffs/mock_data/diff_with_commit.js b/spec/frontend/diffs/mock_data/diff_with_commit.js index d646294ee84..f3b39bd3577 100644 --- a/spec/frontend/diffs/mock_data/diff_with_commit.js +++ b/spec/frontend/diffs/mock_data/diff_with_commit.js @@ -1,7 +1,5 @@ const FIXTURE = 'merge_request_diffs/with_commit.json'; -preloadFixtures(FIXTURE); - export default function getDiffWithCommit() { return getJSONFixture(FIXTURE); } diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index dcb58f7a380..6af38590610 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -275,24 +275,28 @@ describe('DiffsStoreUtils', () => { describe('trimFirstCharOfLineContent', () => { it('trims the line when it starts with a space', () => { + // eslint-disable-next-line import/no-deprecated expect(utils.trimFirstCharOfLineContent({ rich_text: ' diff' })).toEqual({ rich_text: 'diff', }); }); it('trims the line when it starts with a +', () => { + // eslint-disable-next-line import/no-deprecated expect(utils.trimFirstCharOfLineContent({ rich_text: '+diff' })).toEqual({ rich_text: 'diff', }); }); it('trims the line when it starts with a -', () => { + // eslint-disable-next-line import/no-deprecated expect(utils.trimFirstCharOfLineContent({ rich_text: '-diff' })).toEqual({ rich_text: 'diff', }); }); it('does not trims the line when it starts with a letter', () => { + // eslint-disable-next-line import/no-deprecated expect(utils.trimFirstCharOfLineContent({ rich_text: 'diff' })).toEqual({ rich_text: 'diff', }); @@ -303,12 +307,14 @@ describe('DiffsStoreUtils', () => { rich_text: ' diff', }; + // eslint-disable-next-line import/no-deprecated utils.trimFirstCharOfLineContent(lineObj); expect(lineObj).toEqual({ rich_text: ' diff' }); }); it('handles a undefined or null parameter', () => { + // eslint-disable-next-line import/no-deprecated expect(utils.trimFirstCharOfLineContent()).toEqual({}); }); }); diff --git a/spec/frontend/diffs/utils/file_reviews_spec.js b/spec/frontend/diffs/utils/file_reviews_spec.js index a58c19a7245..230ec12409c 100644 --- a/spec/frontend/diffs/utils/file_reviews_spec.js +++ b/spec/frontend/diffs/utils/file_reviews_spec.js @@ -49,11 +49,11 @@ describe('File Review(s) utilities', () => { it.each` mrReviews | files | fileReviews - ${{}} | ${[file1, file2]} | ${[false, false]} - ${{ abc: ['123'] }} | ${[file1, file2]} | ${[true, false]} - ${{ abc: ['098'] }} | ${[file1, file2]} | ${[false, true]} - ${{ def: ['123'] }} | ${[file1, file2]} | ${[false, false]} - ${{ abc: ['123'], def: ['098'] }} | ${[]} | ${[]} + ${{}} | ${[file1, file2]} | ${{ 123: false, '098': false }} + ${{ abc: ['123'] }} | ${[file1, file2]} | ${{ 123: true, '098': false }} + ${{ abc: ['098'] }} | ${[file1, file2]} | ${{ 123: false, '098': true }} + ${{ def: ['123'] }} | ${[file1, file2]} | ${{ 123: false, '098': false }} + ${{ abc: ['123'], def: ['098'] }} | ${[]} | ${{}} `( 'returns $fileReviews based on the diff files in state and the existing reviews $reviews', ({ mrReviews, files, fileReviews }) => { diff --git a/spec/frontend/diffs/utils/preferences_spec.js b/spec/frontend/diffs/utils/preferences_spec.js index b09db2c1003..2dcc71dc188 100644 --- a/spec/frontend/diffs/utils/preferences_spec.js +++ b/spec/frontend/diffs/utils/preferences_spec.js @@ -5,32 +5,25 @@ import { DIFF_VIEW_ALL_FILES, } from '~/diffs/constants'; import { fileByFile } from '~/diffs/utils/preferences'; -import { getParameterValues } from '~/lib/utils/url_utility'; - -jest.mock('~/lib/utils/url_utility'); describe('diffs preferences', () => { describe('fileByFile', () => { + afterEach(() => { + Cookies.remove(DIFF_FILE_BY_FILE_COOKIE_NAME); + }); + it.each` - result | preference | cookie | searchParam - ${false} | ${false} | ${undefined} | ${undefined} - ${true} | ${true} | ${undefined} | ${undefined} - ${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE} | ${undefined} - ${false} | ${true} | ${DIFF_VIEW_ALL_FILES} | ${undefined} - ${true} | ${false} | ${undefined} | ${[DIFF_VIEW_FILE_BY_FILE]} - ${false} | ${true} | ${undefined} | ${[DIFF_VIEW_ALL_FILES]} - ${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE} | ${[DIFF_VIEW_FILE_BY_FILE]} - ${true} | ${true} | ${DIFF_VIEW_ALL_FILES} | ${[DIFF_VIEW_FILE_BY_FILE]} - ${false} | ${false} | ${DIFF_VIEW_ALL_FILES} | ${[DIFF_VIEW_ALL_FILES]} - ${false} | ${true} | ${DIFF_VIEW_FILE_BY_FILE} | ${[DIFF_VIEW_ALL_FILES]} + result | preference | cookie + ${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE} + ${false} | ${true} | ${DIFF_VIEW_ALL_FILES} + ${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE} + ${false} | ${true} | ${DIFF_VIEW_ALL_FILES} + ${false} | ${false} | ${DIFF_VIEW_ALL_FILES} + ${true} | ${true} | ${DIFF_VIEW_FILE_BY_FILE} `( - 'should return $result when { preference: $preference, cookie: $cookie, search: $searchParam }', - ({ result, preference, cookie, searchParam }) => { - if (cookie) { - Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, cookie); - } - - getParameterValues.mockReturnValue(searchParam); + 'should return $result when { preference: $preference, cookie: $cookie }', + ({ result, preference, cookie }) => { + Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, cookie); expect(fileByFile(preference)).toBe(result); }, diff --git a/spec/frontend/emoji/components/category_spec.js b/spec/frontend/emoji/components/category_spec.js new file mode 100644 index 00000000000..afd36a1eb88 --- /dev/null +++ b/spec/frontend/emoji/components/category_spec.js @@ -0,0 +1,49 @@ +import { GlIntersectionObserver } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import Category from '~/emoji/components/category.vue'; +import EmojiGroup from '~/emoji/components/emoji_group.vue'; + +let wrapper; +function factory(propsData = {}) { + wrapper = shallowMount(Category, { propsData }); +} + +describe('Emoji category component', () => { + afterEach(() => { + wrapper.destroy(); + }); + + beforeEach(() => { + factory({ + category: 'Activity', + emojis: [['thumbsup'], ['thumbsdown']], + }); + }); + + it('renders emoji groups', () => { + expect(wrapper.findAll(EmojiGroup).length).toBe(2); + }); + + it('renders group', async () => { + await wrapper.setData({ renderGroup: true }); + + expect(wrapper.find(EmojiGroup).attributes('rendergroup')).toBe('true'); + }); + + it('renders group on appear', async () => { + wrapper.find(GlIntersectionObserver).vm.$emit('appear'); + + await nextTick(); + + expect(wrapper.find(EmojiGroup).attributes('rendergroup')).toBe('true'); + }); + + it('emits appear event on appear', async () => { + wrapper.find(GlIntersectionObserver).vm.$emit('appear'); + + await nextTick(); + + expect(wrapper.emitted().appear[0]).toEqual(['Activity']); + }); +}); diff --git a/spec/frontend/emoji/components/emoji_group_spec.js b/spec/frontend/emoji/components/emoji_group_spec.js new file mode 100644 index 00000000000..1aca2fbb8fc --- /dev/null +++ b/spec/frontend/emoji/components/emoji_group_spec.js @@ -0,0 +1,56 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import EmojiGroup from '~/emoji/components/emoji_group.vue'; + +Vue.config.ignoredElements = ['gl-emoji']; + +let wrapper; +function factory(propsData = {}) { + wrapper = extendedWrapper( + shallowMount(EmojiGroup, { + propsData, + }), + ); +} + +describe('Emoji group component', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('does not render any buttons', () => { + factory({ + emojis: [], + renderGroup: false, + clickEmoji: jest.fn(), + }); + + expect(wrapper.findByTestId('emoji-button').exists()).toBe(false); + }); + + it('renders emojis', () => { + factory({ + emojis: ['thumbsup', 'thumbsdown'], + renderGroup: true, + clickEmoji: jest.fn(), + }); + + expect(wrapper.findAllByTestId('emoji-button').exists()).toBe(true); + expect(wrapper.findAllByTestId('emoji-button').length).toBe(2); + }); + + it('calls clickEmoji', () => { + const clickEmoji = jest.fn(); + + factory({ + emojis: ['thumbsup', 'thumbsdown'], + renderGroup: true, + clickEmoji, + }); + + wrapper.findByTestId('emoji-button').trigger('click'); + + expect(clickEmoji).toHaveBeenCalledWith('thumbsup'); + }); +}); diff --git a/spec/frontend/emoji/components/emoji_list_spec.js b/spec/frontend/emoji/components/emoji_list_spec.js new file mode 100644 index 00000000000..9dc73ef191e --- /dev/null +++ b/spec/frontend/emoji/components/emoji_list_spec.js @@ -0,0 +1,73 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import EmojiList from '~/emoji/components/emoji_list.vue'; + +jest.mock('~/emoji', () => ({ + initEmojiMap: jest.fn(() => Promise.resolve()), + searchEmoji: jest.fn((search) => [{ emoji: { name: search } }]), + getEmojiCategoryMap: jest.fn(() => + Promise.resolve({ + activity: ['thumbsup', 'thumbsdown'], + }), + ), +})); + +let wrapper; +async function factory(render, propsData = { searchValue: '' }) { + wrapper = extendedWrapper( + shallowMount(EmojiList, { + propsData, + scopedSlots: { + default: '<div data-testid="default-slot">{{props.filteredCategories}}</div>', + }, + }), + ); + + // Wait for categories to be set + await nextTick(); + + if (render) { + wrapper.setData({ render: true }); + + // Wait for component to render + await nextTick(); + } +} + +const findDefaultSlot = () => wrapper.findByTestId('default-slot'); + +describe('Emoji list component', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('does not render until render is set', async () => { + await factory(false); + + expect(findDefaultSlot().exists()).toBe(false); + }); + + it('renders with none filtered list', async () => { + await factory(true); + + expect(JSON.parse(findDefaultSlot().text())).toEqual({ + activity: { + emojis: [['thumbsup', 'thumbsdown']], + height: expect.any(Number), + top: expect.any(Number), + }, + }); + }); + + it('renders filtered list of emojis', async () => { + await factory(true, { searchValue: 'smile' }); + + expect(JSON.parse(findDefaultSlot().text())).toEqual({ + search: { + emojis: [['smile']], + height: expect.any(Number), + }, + }); + }); +}); diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index 50d84b19ce8..542cf58b079 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -97,13 +97,21 @@ describe('Environment', () => { jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {}); wrapper.find('.gl-pagination li:nth-child(3) .page-link').trigger('click'); - expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: 'available', page: '2' }); + expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ + scope: 'available', + page: '2', + nested: true, + }); }); it('should make an API request when using tabs', () => { jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {}); findEnvironmentsTabStopped().trigger('click'); - expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: 'stopped', page: '1' }); + expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ + scope: 'stopped', + page: '1', + nested: true, + }); }); it('should not make the same API request when clicking on the current scope tab', () => { diff --git a/spec/frontend/environments/folder/environments_folder_view_spec.js b/spec/frontend/environments/folder/environments_folder_view_spec.js index 3943e89c6cf..d02ed8688c6 100644 --- a/spec/frontend/environments/folder/environments_folder_view_spec.js +++ b/spec/frontend/environments/folder/environments_folder_view_spec.js @@ -103,13 +103,18 @@ describe('Environments Folder View', () => { expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: wrapper.vm.scope, page: '10', + nested: true, }); }); it('should make an API request when using tabs', () => { jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {}); findEnvironmentsTabStopped().trigger('click'); - expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: 'stopped', page: '1' }); + expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ + scope: 'stopped', + page: '1', + nested: true, + }); }); }); }); @@ -161,7 +166,11 @@ describe('Environments Folder View', () => { it('should set page to 1', () => { jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {}); wrapper.vm.onChangeTab('stopped'); - expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: 'stopped', page: '1' }); + expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ + scope: 'stopped', + page: '1', + nested: true, + }); }); }); @@ -172,6 +181,7 @@ describe('Environments Folder View', () => { expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: wrapper.vm.scope, page: '4', + nested: true, }); }); }); diff --git a/spec/frontend/experimentation/experiment_tracking_spec.js b/spec/frontend/experimentation/experiment_tracking_spec.js new file mode 100644 index 00000000000..20f45a7015a --- /dev/null +++ b/spec/frontend/experimentation/experiment_tracking_spec.js @@ -0,0 +1,80 @@ +import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; +import ExperimentTracking from '~/experimentation/experiment_tracking'; +import { getExperimentData } from '~/experimentation/utils'; +import Tracking from '~/tracking'; + +let experimentTracking; +let label; +let property; + +jest.mock('~/tracking'); +jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() })); + +const setup = () => { + experimentTracking = new ExperimentTracking('sidebar_experiment', { label, property }); +}; + +beforeEach(() => { + document.body.dataset.page = 'issues-page'; +}); + +afterEach(() => { + label = undefined; + property = undefined; +}); + +describe('event', () => { + beforeEach(() => { + getExperimentData.mockReturnValue(undefined); + }); + + describe('when experiment data exists for experimentName', () => { + beforeEach(() => { + getExperimentData.mockReturnValue('experiment-data'); + setup(); + }); + + describe('when providing options', () => { + label = 'sidebar-drawer'; + property = 'dark-mode'; + + it('passes them to the tracking call', () => { + experimentTracking.event('click_sidebar_close'); + + expect(Tracking.event).toHaveBeenCalledTimes(1); + expect(Tracking.event).toHaveBeenCalledWith('issues-page', 'click_sidebar_close', { + label: 'sidebar-drawer', + property: 'dark-mode', + context: { + schema: TRACKING_CONTEXT_SCHEMA, + data: 'experiment-data', + }, + }); + }); + }); + + it('tracks with the correct context', () => { + experimentTracking.event('click_sidebar_trigger'); + + expect(Tracking.event).toHaveBeenCalledTimes(1); + expect(Tracking.event).toHaveBeenCalledWith('issues-page', 'click_sidebar_trigger', { + context: { + schema: TRACKING_CONTEXT_SCHEMA, + data: 'experiment-data', + }, + }); + }); + }); + + describe('when experiment data does NOT exists for the experimentName', () => { + beforeEach(() => { + setup(); + }); + + it('does not track', () => { + experimentTracking.event('click_sidebar_close'); + + expect(Tracking.event).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/experimentation/utils_spec.js b/spec/frontend/experimentation/utils_spec.js new file mode 100644 index 00000000000..87dd2d595ba --- /dev/null +++ b/spec/frontend/experimentation/utils_spec.js @@ -0,0 +1,38 @@ +import * as experimentUtils from '~/experimentation/utils'; + +const TEST_KEY = 'abc'; + +describe('experiment Utilities', () => { + const oldGon = window.gon; + + afterEach(() => { + window.gon = oldGon; + }); + + describe('getExperimentData', () => { + it.each` + gon | input | output + ${{ experiment: { [TEST_KEY]: '_data_' } }} | ${[TEST_KEY]} | ${'_data_'} + ${{}} | ${[TEST_KEY]} | ${undefined} + `('with input=$input and gon=$gon, returns $output', ({ gon, input, output }) => { + window.gon = gon; + + expect(experimentUtils.getExperimentData(...input)).toEqual(output); + }); + }); + + describe('isExperimentVariant', () => { + it.each` + gon | input | output + ${{ experiment: { [TEST_KEY]: { variant: 'control' } } }} | ${[TEST_KEY, 'control']} | ${true} + ${{ experiment: { [TEST_KEY]: { variant: '_variant_name' } } }} | ${[TEST_KEY, '_variant_name']} | ${true} + ${{ experiment: { [TEST_KEY]: { variant: '_variant_name' } } }} | ${[TEST_KEY, '_bogus_name']} | ${false} + ${{ experiment: { [TEST_KEY]: { variant: '_variant_name' } } }} | ${['boguskey', '_variant_name']} | ${false} + ${{}} | ${[TEST_KEY, '_variant_name']} | ${false} + `('with input=$input and gon=$gon, returns $output', ({ gon, input, output }) => { + window.gon = gon; + + expect(experimentUtils.isExperimentVariant(...input)).toEqual(output); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js index 84e71ffd204..27ec6a7280f 100644 --- a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js +++ b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js @@ -32,8 +32,9 @@ describe('Configure Feature Flags Modal', () => { }); }; - const findGlModal = () => wrapper.find(GlModal); + const findGlModal = () => wrapper.findComponent(GlModal); const findPrimaryAction = () => findGlModal().props('actionPrimary'); + const findSecondaryAction = () => findGlModal().props('actionSecondary'); const findProjectNameInput = () => wrapper.find('#project_name_verification'); const findDangerGlAlert = () => wrapper.findAll(GlAlert).filter((c) => c.props('variant') === 'danger'); @@ -42,18 +43,18 @@ describe('Configure Feature Flags Modal', () => { afterEach(() => wrapper.destroy()); beforeEach(factory); - it('should have Primary and Cancel actions', () => { - expect(findGlModal().props('actionCancel').text).toBe('Close'); - expect(findPrimaryAction().text).toBe('Regenerate instance ID'); + it('should have Primary and Secondary actions', () => { + expect(findPrimaryAction().text).toBe('Close'); + expect(findSecondaryAction().text).toBe('Regenerate instance ID'); }); - it('should default disable the primary action', async () => { - const [{ disabled }] = findPrimaryAction().attributes; + it('should default disable the primary action', () => { + const [{ disabled }] = findSecondaryAction().attributes; expect(disabled).toBe(true); }); it('should emit a `token` event when clicking on the Primary action', async () => { - findGlModal().vm.$emit('primary', mockEvent); + findGlModal().vm.$emit('secondary', mockEvent); await wrapper.vm.$nextTick(); expect(wrapper.emitted('token')).toEqual([[]]); expect(mockEvent.preventDefault).toHaveBeenCalled(); @@ -112,10 +113,10 @@ describe('Configure Feature Flags Modal', () => { afterEach(() => wrapper.destroy()); beforeEach(factory); - it('should enable the primary action', async () => { + it('should enable the secondary action', async () => { findProjectNameInput().vm.$emit('input', provide.projectName); await wrapper.vm.$nextTick(); - const [{ disabled }] = findPrimaryAction().attributes; + const [{ disabled }] = findSecondaryAction().attributes; expect(disabled).toBe(false); }); }); @@ -124,8 +125,8 @@ describe('Configure Feature Flags Modal', () => { afterEach(() => wrapper.destroy()); beforeEach(factory.bind(null, { canUserRotateToken: false })); - it('should not display the primary action', async () => { - expect(findPrimaryAction()).toBe(null); + it('should not display the primary action', () => { + expect(findSecondaryAction()).toBe(null); }); it('should not display regenerating instance ID', async () => { diff --git a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js index e2717b98ea9..2fd8e524e7a 100644 --- a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js +++ b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js @@ -150,5 +150,12 @@ describe('Edit feature flag form', () => { label: 'feature_flag_toggle', }); }); + + it('should render the toggle with a visually hidden label', () => { + expect(wrapper.find(GlToggle).props()).toMatchObject({ + label: 'Feature flag status', + labelPosition: 'hidden', + }); + }); }); }); diff --git a/spec/frontend/feature_flags/components/feature_flags_table_spec.js b/spec/frontend/feature_flags/components/feature_flags_table_spec.js index 8f4d39d4a11..816bc9b9707 100644 --- a/spec/frontend/feature_flags/components/feature_flags_table_spec.js +++ b/spec/frontend/feature_flags/components/feature_flags_table_spec.js @@ -129,7 +129,10 @@ describe('Feature flag table', () => { it('should have a toggle', () => { expect(toggle.exists()).toBe(true); - expect(toggle.props('value')).toBe(true); + expect(toggle.props()).toMatchObject({ + label: FeatureFlagsTable.i18n.toggleLabel, + value: true, + }); }); it('should trigger a toggle event', () => { diff --git a/spec/frontend/filtered_search/dropdown_user_spec.js b/spec/frontend/filtered_search/dropdown_user_spec.js index 0e2d2ee6c09..961587f7146 100644 --- a/spec/frontend/filtered_search/dropdown_user_spec.js +++ b/spec/frontend/filtered_search/dropdown_user_spec.js @@ -78,7 +78,6 @@ describe('Dropdown User', () => { describe('hideCurrentUser', () => { const fixtureTemplate = 'issues/issue_list.html'; - preloadFixtures(fixtureTemplate); let dropdown; let authorFilterDropdownElement; diff --git a/spec/frontend/filtered_search/dropdown_utils_spec.js b/spec/frontend/filtered_search/dropdown_utils_spec.js index 32d1f909d0b..49e14f58630 100644 --- a/spec/frontend/filtered_search/dropdown_utils_spec.js +++ b/spec/frontend/filtered_search/dropdown_utils_spec.js @@ -5,7 +5,6 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered describe('Dropdown Utils', () => { const issueListFixture = 'issues/issue_list.html'; - preloadFixtures(issueListFixture); describe('getEscapedText', () => { it('should return same word when it has no space', () => { diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js index a2082271efe..772fa7d07ed 100644 --- a/spec/frontend/filtered_search/visual_token_value_spec.js +++ b/spec/frontend/filtered_search/visual_token_value_spec.js @@ -133,8 +133,6 @@ describe('Filtered Search Visual Tokens', () => { const jsonFixtureName = 'labels/project_labels.json'; const dummyEndpoint = '/dummy/endpoint'; - preloadFixtures(jsonFixtureName); - let labelData; beforeAll(() => { diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb index a027247bd0d..d6f6ed97626 100644 --- a/spec/frontend/fixtures/issues.rb +++ b/spec/frontend/fixtures/issues.rb @@ -16,6 +16,8 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :contr end before do + stub_feature_flags(boards_filtered_search: false) + project.add_maintainer(user) sign_in(user) end diff --git a/spec/frontend/fixtures/pipelines.rb b/spec/frontend/fixtures/pipelines.rb index b4b7f0e332f..2a538352abe 100644 --- a/spec/frontend/fixtures/pipelines.rb +++ b/spec/frontend/fixtures/pipelines.rb @@ -5,16 +5,22 @@ require 'spec_helper' RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers - let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} - let(:project) { create(:project, :repository, namespace: namespace, path: 'pipelines-project') } - let(:commit) { create(:commit, project: project) } - let(:commit_without_author) { RepoHelpers.another_sample_commit } - let!(:user) { create(:user, developer_projects: [project], email: commit.author_email) } - let!(:pipeline) { create(:ci_pipeline, project: project, sha: commit.id, user: user) } + let_it_be(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'pipelines-project') } + + let_it_be(:commit_without_author) { RepoHelpers.another_sample_commit } let!(:pipeline_without_author) { create(:ci_pipeline, project: project, sha: commit_without_author.id) } - let!(:pipeline_without_commit) { create(:ci_pipeline, status: :success, project: project, sha: '0000') } + let!(:build_pipeline_without_author) { create(:ci_build, pipeline: pipeline_without_author, stage: 'test') } - render_views + let_it_be(:pipeline_without_commit) { create(:ci_pipeline, status: :success, project: project, sha: '0000') } + let!(:build_pipeline_without_commit) { create(:ci_build, pipeline: pipeline_without_commit, stage: 'test') } + + let(:commit) { create(:commit, project: project) } + let(:user) { create(:user, developer_projects: [project], email: commit.author_email) } + let!(:pipeline) { create(:ci_pipeline, :with_test_reports, project: project, sha: commit.id, user: user) } + let!(:build_success) { create(:ci_build, pipeline: pipeline, stage: 'build') } + let!(:build_test) { create(:ci_build, pipeline: pipeline, stage: 'test') } + let!(:build_deploy_failed) { create(:ci_build, status: :failed, pipeline: pipeline, stage: 'deploy') } before(:all) do clean_frontend_fixtures('pipelines/') @@ -32,4 +38,14 @@ RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :co expect(response).to be_successful end + + it "pipelines/test_report.json" do + get :test_report, params: { + namespace_id: namespace, + project_id: project, + id: pipeline.id + }, format: :json + + expect(response).to be_successful + end end diff --git a/spec/frontend/fixtures/projects.rb b/spec/frontend/fixtures/projects.rb index aa2f7dbed36..778ae218160 100644 --- a/spec/frontend/fixtures/projects.rb +++ b/spec/frontend/fixtures/projects.rb @@ -3,13 +3,14 @@ require 'spec_helper' RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do + include ApiHelpers include JavaScriptFixturesHelpers runners_token = 'runnerstoken:intabulasreferre' let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} - let(:project) { create(:project, namespace: namespace, path: 'builds-project', runners_token: runners_token) } - let(:project_with_repo) { create(:project, :repository, description: 'Code and stuff') } + let(:project) { create(:project, namespace: namespace, path: 'builds-project', runners_token: runners_token, avatar: fixture_file_upload('spec/fixtures/dk.png', 'image/png')) } + let(:project_with_repo) { create(:project, :repository, description: 'Code and stuff', avatar: fixture_file_upload('spec/fixtures/dk.png', 'image/png')) } let(:project_variable_populated) { create(:project, namespace: namespace, path: 'builds-project2', runners_token: runners_token) } let(:user) { project.owner } @@ -22,7 +23,6 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do before do project_with_repo.add_maintainer(user) sign_in(user) - allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon') end after do @@ -48,4 +48,31 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do expect(response).to be_successful end end + + describe GraphQL::Query, type: :request do + include GraphqlHelpers + + context 'access token projects query' do + before do + project_variable_populated.add_maintainer(user) + end + + before(:all) do + clean_frontend_fixtures('graphql/projects/access_tokens') + end + + fragment_paths = ['graphql_shared/fragments/pageInfo.fragment.graphql'] + base_input_path = 'access_tokens/graphql/queries/' + base_output_path = 'graphql/projects/access_tokens/' + query_name = 'get_projects.query.graphql' + + it "#{base_output_path}#{query_name}.json" do + query = get_graphql_query_as_string("#{base_input_path}#{query_name}", fragment_paths) + + post_graphql(query, current_user: user, variables: { search: '', first: 2 }) + + expect_graphql_errors_to_be_empty + end + end + end end diff --git a/spec/frontend/fixtures/test_report.rb b/spec/frontend/fixtures/test_report.rb deleted file mode 100644 index 3d09078ba68..00000000000 --- a/spec/frontend/fixtures/test_report.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe Projects::PipelinesController, "(JavaScript fixtures)", type: :controller do - include JavaScriptFixturesHelpers - - let(:namespace) { create(:namespace, name: "frontend-fixtures") } - let(:project) { create(:project, :repository, namespace: namespace, path: "pipelines-project") } - let(:commit) { create(:commit, project: project) } - let(:user) { create(:user, developer_projects: [project], email: commit.author_email) } - let(:pipeline) { create(:ci_pipeline, :with_test_reports, project: project, user: user) } - - render_views - - before do - sign_in(user) - end - - it "pipelines/test_report.json" do - get :test_report, params: { - namespace_id: project.namespace, - project_id: project, - id: pipeline.id - }, format: :json - - expect(response).to be_successful - end -end diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 08368e1f2ca..13dbda9cf55 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -576,55 +576,95 @@ describe('GfmAutoComplete', () => { }); }); - describe('Members.templateFunction', () => { - it('should return html with avatarTag and username', () => { - expect( - GfmAutoComplete.Members.templateFunction({ - avatarTag: 'IMG', - username: 'my-group', - title: '', - icon: '', - availabilityStatus: '', - }), - ).toBe('<li>IMG my-group <small></small> </li>'); - }); + describe('GfmAutoComplete.Members', () => { + const member = { + name: 'Marge Simpson', + username: 'msimpson', + search: 'MargeSimpson msimpson', + }; - it('should add icon if icon is set', () => { - expect( - GfmAutoComplete.Members.templateFunction({ - avatarTag: 'IMG', - username: 'my-group', - title: '', - icon: '<i class="icon"/>', - availabilityStatus: '', - }), - ).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>'); - }); + describe('templateFunction', () => { + it('should return html with avatarTag and username', () => { + expect( + GfmAutoComplete.Members.templateFunction({ + avatarTag: 'IMG', + username: 'my-group', + title: '', + icon: '', + availabilityStatus: '', + }), + ).toBe('<li>IMG my-group <small></small> </li>'); + }); - it('should add escaped title if title is set', () => { - expect( - GfmAutoComplete.Members.templateFunction({ - avatarTag: 'IMG', - username: 'my-group', - title: 'MyGroup+', - icon: '<i class="icon"/>', - availabilityStatus: '', - }), - ).toBe('<li>IMG my-group <small>MyGroup+</small> <i class="icon"/></li>'); - }); + it('should add icon if icon is set', () => { + expect( + GfmAutoComplete.Members.templateFunction({ + avatarTag: 'IMG', + username: 'my-group', + title: '', + icon: '<i class="icon"/>', + availabilityStatus: '', + }), + ).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>'); + }); - it('should add user availability status if availabilityStatus is set', () => { - expect( - GfmAutoComplete.Members.templateFunction({ - avatarTag: 'IMG', - username: 'my-group', - title: '', - icon: '<i class="icon"/>', - availabilityStatus: '<span class="gl-text-gray-500"> (Busy)</span>', - }), - ).toBe( - '<li>IMG my-group <small><span class="gl-text-gray-500"> (Busy)</span></small> <i class="icon"/></li>', - ); + it('should add escaped title if title is set', () => { + expect( + GfmAutoComplete.Members.templateFunction({ + avatarTag: 'IMG', + username: 'my-group', + title: 'MyGroup+', + icon: '<i class="icon"/>', + availabilityStatus: '', + }), + ).toBe('<li>IMG my-group <small>MyGroup+</small> <i class="icon"/></li>'); + }); + + it('should add user availability status if availabilityStatus is set', () => { + expect( + GfmAutoComplete.Members.templateFunction({ + avatarTag: 'IMG', + username: 'my-group', + title: '', + icon: '<i class="icon"/>', + availabilityStatus: '<span class="gl-text-gray-500"> (Busy)</span>', + }), + ).toBe( + '<li>IMG my-group <small><span class="gl-text-gray-500"> (Busy)</span></small> <i class="icon"/></li>', + ); + }); + + describe('nameOrUsernameStartsWith', () => { + it.each` + query | result + ${'mar'} | ${true} + ${'msi'} | ${true} + ${'margesimpson'} | ${true} + ${'msimpson'} | ${true} + ${'arge'} | ${false} + ${'rgesimp'} | ${false} + ${'maria'} | ${false} + ${'homer'} | ${false} + `('returns $result for $query', ({ query, result }) => { + expect(GfmAutoComplete.Members.nameOrUsernameStartsWith(member, query)).toBe(result); + }); + }); + + describe('nameOrUsernameIncludes', () => { + it.each` + query | result + ${'mar'} | ${true} + ${'msi'} | ${true} + ${'margesimpson'} | ${true} + ${'msimpson'} | ${true} + ${'arge'} | ${true} + ${'rgesimp'} | ${true} + ${'maria'} | ${false} + ${'homer'} | ${false} + `('returns $result for $query', ({ query, result }) => { + expect(GfmAutoComplete.Members.nameOrUsernameIncludes(member, query)).toBe(result); + }); + }); }); }); diff --git a/spec/frontend/gl_field_errors_spec.js b/spec/frontend/gl_field_errors_spec.js index a1737211252..ada3b34e6b1 100644 --- a/spec/frontend/gl_field_errors_spec.js +++ b/spec/frontend/gl_field_errors_spec.js @@ -8,8 +8,6 @@ describe('GL Style Field Errors', () => { testContext = {}; }); - preloadFixtures('static/gl_field_errors.html'); - beforeEach(() => { loadFixtures('static/gl_field_errors.html'); const $form = $('form.gl-show-field-errors'); diff --git a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap index 0fc4343ec3c..2e02159a20c 100644 --- a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap +++ b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap @@ -31,8 +31,11 @@ exports[`grafana integration component default state to match the default snapsh class="js-section-sub-header" > - Embed Grafana charts in GitLab issues. - + Set up Grafana authentication to embed Grafana panels in GitLab Flavored Markdown. + + <gl-link-stub> + Learn more. + </gl-link-stub> </p> </div> @@ -56,13 +59,13 @@ exports[`grafana integration component default state to match the default snapsh > <gl-form-input-stub id="grafana-url" - placeholder="https://my-url.grafana.net/" + placeholder="https://my-grafana.example.com/" value="http://test.host" /> </gl-form-group-stub> <gl-form-group-stub - label="API Token" + label="API token" label-for="grafana-token" > <gl-form-input-stub @@ -74,7 +77,7 @@ exports[`grafana integration component default state to match the default snapsh class="form-text text-muted" > - Enter the Grafana API Token. + Enter the Grafana API token. <a href="https://grafana.com/docs/http_api/auth/#create-api-token" @@ -82,7 +85,7 @@ exports[`grafana integration component default state to match the default snapsh target="_blank" > - More information + More information. <gl-icon-stub class="vertical-align-middle" @@ -101,7 +104,7 @@ exports[`grafana integration component default state to match the default snapsh variant="success" > - Save Changes + Save changes </gl-button-stub> </form> diff --git a/spec/frontend/grafana_integration/components/grafana_integration_spec.js b/spec/frontend/grafana_integration/components/grafana_integration_spec.js index ad1260d8030..f1a8e6fe2dc 100644 --- a/spec/frontend/grafana_integration/components/grafana_integration_spec.js +++ b/spec/frontend/grafana_integration/components/grafana_integration_spec.js @@ -62,7 +62,7 @@ describe('grafana integration component', () => { wrapper = shallowMount(GrafanaIntegration, { store }); expect(wrapper.find('.js-section-sub-header').text()).toContain( - 'Embed Grafana charts in GitLab issues.', + 'Set up Grafana authentication to embed Grafana panels in GitLab Flavored Markdown.\n Learn more.', ); }); }); diff --git a/spec/frontend/graphql_shared/utils_spec.js b/spec/frontend/graphql_shared/utils_spec.js index d392b0f0575..56bfb02ea4a 100644 --- a/spec/frontend/graphql_shared/utils_spec.js +++ b/spec/frontend/graphql_shared/utils_spec.js @@ -2,6 +2,8 @@ import { getIdFromGraphQLId, convertToGraphQLId, convertToGraphQLIds, + convertFromGraphQLIds, + convertNodeIdsFromGraphQLIds, } from '~/graphql_shared/utils'; const mockType = 'Group'; @@ -81,3 +83,35 @@ describe('convertToGraphQLIds', () => { expect(() => convertToGraphQLIds(type, ids)).toThrow(new TypeError(message)); }); }); + +describe('convertFromGraphQLIds', () => { + it.each` + ids | expected + ${[mockGid]} | ${[mockId]} + ${[mockGid, 'invalid id']} | ${[mockId, null]} + `('converts $ids from GraphQL Ids', ({ ids, expected }) => { + expect(convertFromGraphQLIds(ids)).toEqual(expected); + }); + + it("throws TypeError if `ids` parameter isn't an array", () => { + expect(() => convertFromGraphQLIds('invalid')).toThrow( + new TypeError('ids must be an array; got string'), + ); + }); +}); + +describe('convertNodeIdsFromGraphQLIds', () => { + it.each` + nodes | expected + ${[{ id: mockGid, name: 'foo bar' }, { id: mockGid, name: 'baz' }]} | ${[{ id: mockId, name: 'foo bar' }, { id: mockId, name: 'baz' }]} + ${[{ name: 'foo bar' }]} | ${[{ name: 'foo bar' }]} + `('converts `id` properties in $nodes from GraphQL Id', ({ nodes, expected }) => { + expect(convertNodeIdsFromGraphQLIds(nodes)).toEqual(expected); + }); + + it("throws TypeError if `nodes` parameter isn't an array", () => { + expect(() => convertNodeIdsFromGraphQLIds('invalid')).toThrow( + new TypeError('nodes must be an array; got string'), + ); + }); +}); diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js index 4fcc9bafa46..5a9f640392f 100644 --- a/spec/frontend/groups/components/group_item_spec.js +++ b/spec/frontend/groups/components/group_item_spec.js @@ -188,7 +188,7 @@ describe('GroupItemComponent', () => { }); it('should render component template correctly', () => { - const visibilityIconEl = vm.$el.querySelector('.item-visibility'); + const visibilityIconEl = vm.$el.querySelector('[data-testid="group-visibility-icon"]'); expect(vm.$el.getAttribute('id')).toBe('group-55'); expect(vm.$el.classList.contains('group-row')).toBeTruthy(); @@ -209,8 +209,7 @@ describe('GroupItemComponent', () => { expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined(); expect(visibilityIconEl).not.toBe(null); - expect(visibilityIconEl.title).toBe(vm.visibilityTooltip); - expect(visibilityIconEl.querySelectorAll('svg').length).toBeGreaterThan(0); + expect(visibilityIconEl.getAttribute('title')).toBe(vm.visibilityTooltip); expect(vm.$el.querySelector('.access-type')).toBeDefined(); expect(vm.$el.querySelector('.description')).toBeDefined(); diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js index 27305abfafa..4ca6d7259bd 100644 --- a/spec/frontend/header_spec.js +++ b/spec/frontend/header_spec.js @@ -15,7 +15,6 @@ describe('Header', () => { $(document).trigger('todo:toggle', newCount); } - preloadFixtures(fixtureTemplate); beforeEach(() => { initTodoToggle(); loadFixtures(fixtureTemplate); diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js index 2b567816ce8..083a2a73b24 100644 --- a/spec/frontend/ide/components/commit_sidebar/form_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js @@ -14,6 +14,7 @@ import { createBranchChangedCommitError, branchAlreadyExistsCommitError, } from '~/ide/lib/errors'; +import { MSG_CANNOT_PUSH_CODE_SHORT } from '~/ide/messages'; import { createStore } from '~/ide/stores'; import { COMMIT_TO_NEW_BRANCH } from '~/ide/stores/modules/commit/constants'; @@ -84,8 +85,8 @@ describe('IDE commit form', () => { ${'when there are no changes'} | ${[]} | ${{ pushCode: true }} | ${goToEditView} | ${findBeginCommitButtonData} | ${true} | ${''} ${'when there are changes'} | ${['test']} | ${{ pushCode: true }} | ${goToEditView} | ${findBeginCommitButtonData} | ${false} | ${''} ${'when there are changes'} | ${['test']} | ${{ pushCode: true }} | ${goToCommitView} | ${findCommitButtonData} | ${false} | ${''} - ${'when user cannot push'} | ${['test']} | ${{ pushCode: false }} | ${goToEditView} | ${findBeginCommitButtonData} | ${true} | ${CommitForm.MSG_CANNOT_PUSH_CODE} - ${'when user cannot push'} | ${['test']} | ${{ pushCode: false }} | ${goToCommitView} | ${findCommitButtonData} | ${true} | ${CommitForm.MSG_CANNOT_PUSH_CODE} + ${'when user cannot push'} | ${['test']} | ${{ pushCode: false }} | ${goToEditView} | ${findBeginCommitButtonData} | ${true} | ${MSG_CANNOT_PUSH_CODE_SHORT} + ${'when user cannot push'} | ${['test']} | ${{ pushCode: false }} | ${goToCommitView} | ${findCommitButtonData} | ${true} | ${MSG_CANNOT_PUSH_CODE_SHORT} `('$desc', ({ stagedFiles, userPermissions, viewFn, buttonFn, disabled, tooltip }) => { beforeEach(async () => { store.state.stagedFiles = stagedFiles; diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js index c9d19c18d03..bd251f78654 100644 --- a/spec/frontend/ide/components/ide_spec.js +++ b/spec/frontend/ide/components/ide_spec.js @@ -4,6 +4,7 @@ import Vuex from 'vuex'; import waitForPromises from 'helpers/wait_for_promises'; import ErrorMessage from '~/ide/components/error_message.vue'; import Ide from '~/ide/components/ide.vue'; +import { MSG_CANNOT_PUSH_CODE } from '~/ide/messages'; import { createStore } from '~/ide/stores'; import { file } from '../helpers'; import { projectData } from '../mock_data'; @@ -158,7 +159,7 @@ describe('WebIDE', () => { expect(findAlert().props()).toMatchObject({ dismissible: false, }); - expect(findAlert().text()).toBe(Ide.MSG_CANNOT_PUSH_CODE); + expect(findAlert().text()).toBe(MSG_CANNOT_PUSH_CODE); }); it.each` diff --git a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap index efa58a4a47b..194a619c4aa 100644 --- a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap +++ b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap @@ -10,7 +10,6 @@ exports[`IDE pipelines list when loaded renders empty state when no latestPipeli cansetci="true" class="mb-auto mt-auto" emptystatesvgpath="http://test.host" - helppagepath="http://test.host" /> </div> `; diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js index 58d8c0629fb..a917f4c0230 100644 --- a/spec/frontend/ide/components/pipelines/list_spec.js +++ b/spec/frontend/ide/components/pipelines/list_spec.js @@ -19,7 +19,6 @@ describe('IDE pipelines list', () => { let wrapper; const defaultState = { - links: { ciHelpPagePath: TEST_HOST }, pipelinesEmptyStateSvgPath: TEST_HOST, }; const defaultPipelinesState = { diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index 1985feb1615..a3b327343e5 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -1,11 +1,15 @@ +import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import { Range } from 'monaco-editor'; +import { editor as monacoEditor, Range } from 'monaco-editor'; import Vue from 'vue'; import Vuex from 'vuex'; import '~/behaviors/markdown/render_gfm'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; import waitForPromises from 'helpers/wait_for_promises'; import waitUsingRealTimer from 'helpers/wait_using_real_timer'; +import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data'; +import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN } from '~/editor/constants'; +import EditorLite from '~/editor/editor_lite'; +import { EditorWebIdeExtension } from '~/editor/extensions/editor_lite_webide_ext'; import RepoEditor from '~/ide/components/repo_editor.vue'; import { leftSidebarViews, @@ -13,733 +17,723 @@ import { FILE_VIEW_MODE_PREVIEW, viewerTypes, } from '~/ide/constants'; -import Editor from '~/ide/lib/editor'; +import ModelManager from '~/ide/lib/common/model_manager'; import service from '~/ide/services'; import { createStoreOptions } from '~/ide/stores'; import axios from '~/lib/utils/axios_utils'; +import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import { file } from '../helpers'; -import { exampleConfigs, exampleFiles } from '../lib/editorconfig/mock_data'; + +const defaultFileProps = { + ...file('file.txt'), + content: 'hello world', + active: true, + tempFile: true, +}; +const createActiveFile = (props) => { + return { + ...defaultFileProps, + ...props, + }; +}; + +const dummyFile = { + markdown: (() => + createActiveFile({ + projectId: 'namespace/project', + path: 'sample.md', + name: 'sample.md', + }))(), + binary: (() => + createActiveFile({ + name: 'file.dat', + content: '🐱', // non-ascii binary content, + }))(), + empty: (() => + createActiveFile({ + tempFile: false, + content: '', + raw: '', + }))(), +}; + +const prepareStore = (state, activeFile) => { + const localState = { + openFiles: [activeFile], + projects: { + 'gitlab-org/gitlab': { + branches: { + master: { + name: 'master', + commit: { + id: 'abcdefgh', + }, + }, + }, + }, + }, + currentProjectId: 'gitlab-org/gitlab', + currentBranchId: 'master', + entries: { + [activeFile.path]: activeFile, + }, + }; + const storeOptions = createStoreOptions(); + return new Vuex.Store({ + ...createStoreOptions(), + state: { + ...storeOptions.state, + ...localState, + ...state, + }, + }); +}; describe('RepoEditor', () => { + let wrapper; let vm; - let store; + let createInstanceSpy; + let createDiffInstanceSpy; + let createModelSpy; const waitForEditorSetup = () => new Promise((resolve) => { vm.$once('editorSetup', resolve); }); - const createComponent = () => { - if (vm) { - throw new Error('vm already exists'); - } - vm = createComponentWithStore(Vue.extend(RepoEditor), store, { - file: store.state.openFiles[0], + const createComponent = async ({ state = {}, activeFile = defaultFileProps } = {}) => { + const store = prepareStore(state, activeFile); + wrapper = shallowMount(RepoEditor, { + store, + propsData: { + file: store.state.openFiles[0], + }, + mocks: { + ContentViewer, + }, }); - + await waitForPromises(); + vm = wrapper.vm; jest.spyOn(vm, 'getFileData').mockResolvedValue(); jest.spyOn(vm, 'getRawFileData').mockResolvedValue(); - - vm.$mount(); }; - const createOpenFile = (path) => { - const origFile = store.state.openFiles[0]; - const newFile = { ...origFile, path, key: path, name: 'myfile.txt', content: 'hello world' }; - - store.state.entries[path] = newFile; - - store.state.openFiles = [newFile]; - }; + const findEditor = () => wrapper.find('[data-testid="editor-container"]'); + const findTabs = () => wrapper.findAll('.ide-mode-tabs .nav-links li'); + const findPreviewTab = () => wrapper.find('[data-testid="preview-tab"]'); beforeEach(() => { - const f = { - ...file('file.txt'), - content: 'hello world', - }; - - const storeOptions = createStoreOptions(); - store = new Vuex.Store(storeOptions); - - f.active = true; - f.tempFile = true; - - store.state.openFiles.push(f); - store.state.projects = { - 'gitlab-org/gitlab': { - branches: { - master: { - name: 'master', - commit: { - id: 'abcdefgh', - }, - }, - }, - }, - }; - store.state.currentProjectId = 'gitlab-org/gitlab'; - store.state.currentBranchId = 'master'; - - Vue.set(store.state.entries, f.path, f); + createInstanceSpy = jest.spyOn(EditorLite.prototype, EDITOR_CODE_INSTANCE_FN); + createDiffInstanceSpy = jest.spyOn(EditorLite.prototype, EDITOR_DIFF_INSTANCE_FN); + createModelSpy = jest.spyOn(monacoEditor, 'createModel'); + jest.spyOn(service, 'getFileData').mockResolvedValue(); + jest.spyOn(service, 'getRawFileData').mockResolvedValue(); }); afterEach(() => { - vm.$destroy(); - vm = null; - - Editor.editorInstance.dispose(); + jest.clearAllMocks(); + // create a new model each time, otherwise tests conflict with each other + // because of same model being used in multiple tests + // eslint-disable-next-line no-undef + monaco.editor.getModels().forEach((model) => model.dispose()); + wrapper.destroy(); + wrapper = null; }); - const findEditor = () => vm.$el.querySelector('.multi-file-editor-holder'); - const changeViewMode = (viewMode) => - store.dispatch('editor/updateFileEditor', { path: vm.file.path, data: { viewMode } }); - describe('default', () => { - beforeEach(() => { - createComponent(); - - return waitForEditorSetup(); + it.each` + boolVal | textVal + ${true} | ${'all'} + ${false} | ${'none'} + `('sets renderWhitespace to "$textVal"', async ({ boolVal, textVal } = {}) => { + await createComponent({ + state: { + renderWhitespaceInCode: boolVal, + }, + }); + expect(vm.editorOptions.renderWhitespace).toEqual(textVal); }); - it('sets renderWhitespace to `all`', () => { - vm.$store.state.renderWhitespaceInCode = true; - - expect(vm.editorOptions.renderWhitespace).toEqual('all'); + it('renders an ide container', async () => { + await createComponent(); + expect(findEditor().isVisible()).toBe(true); }); - it('sets renderWhitespace to `none`', () => { - vm.$store.state.renderWhitespaceInCode = false; + it('renders only an edit tab', async () => { + await createComponent(); + const tabs = findTabs(); - expect(vm.editorOptions.renderWhitespace).toEqual('none'); + expect(tabs).toHaveLength(1); + expect(tabs.at(0).text()).toBe('Edit'); }); + }); - it('renders an ide container', () => { - expect(vm.shouldHideEditor).toBeFalsy(); - expect(vm.showEditor).toBe(true); - expect(findEditor()).not.toHaveCss({ display: 'none' }); - }); + describe('when file is markdown', () => { + let mock; + let activeFile; - it('renders only an edit tab', (done) => { - Vue.nextTick(() => { - const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li'); + beforeEach(() => { + activeFile = dummyFile.markdown; - expect(tabs.length).toBe(1); - expect(tabs[0].textContent.trim()).toBe('Edit'); + mock = new MockAdapter(axios); - done(); + mock.onPost(/(.*)\/preview_markdown/).reply(200, { + body: `<p>${defaultFileProps.content}</p>`, }); }); - describe('when file is markdown', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - - mock.onPost(/(.*)\/preview_markdown/).reply(200, { - body: '<p>testing 123</p>', - }); - - Vue.set(vm, 'file', { - ...vm.file, - projectId: 'namespace/project', - path: 'sample.md', - name: 'sample.md', - content: 'testing 123', - }); - - vm.$store.state.entries[vm.file.path] = vm.file; + afterEach(() => { + mock.restore(); + }); - return vm.$nextTick(); - }); + it('renders an Edit and a Preview Tab', async () => { + await createComponent({ activeFile }); + const tabs = findTabs(); - afterEach(() => { - mock.restore(); - }); + expect(tabs).toHaveLength(2); + expect(tabs.at(0).text()).toBe('Edit'); + expect(tabs.at(1).text()).toBe('Preview Markdown'); + }); - it('renders an Edit and a Preview Tab', (done) => { - Vue.nextTick(() => { - const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li'); + it('renders markdown for tempFile', async () => { + // by default files created in the spec are temp: no need for explicitly sending the param + await createComponent({ activeFile }); - expect(tabs.length).toBe(2); - expect(tabs[0].textContent.trim()).toBe('Edit'); - expect(tabs[1].textContent.trim()).toBe('Preview Markdown'); + findPreviewTab().trigger('click'); + await waitForPromises(); + expect(wrapper.find(ContentViewer).html()).toContain(defaultFileProps.content); + }); - done(); - }); + it('shows no tabs when not in Edit mode', async () => { + await createComponent({ + state: { + currentActivityView: leftSidebarViews.review.name, + }, + activeFile, }); + expect(findTabs()).toHaveLength(0); + }); + }); - it('renders markdown for tempFile', (done) => { - vm.file.tempFile = true; - - vm.$nextTick() - .then(() => { - vm.$el.querySelectorAll('.ide-mode-tabs .nav-links a')[1].click(); - }) - .then(waitForPromises) - .then(() => { - expect(vm.$el.querySelector('.preview-container').innerHTML).toContain( - '<p>testing 123</p>', - ); - }) - .then(done) - .catch(done.fail); - }); + describe('when file is binary and not raw', () => { + beforeEach(async () => { + const activeFile = dummyFile.binary; + await createComponent({ activeFile }); + }); - describe('when not in edit mode', () => { - beforeEach(async () => { - await vm.$nextTick(); + it('does not render the IDE', () => { + expect(findEditor().isVisible()).toBe(false); + }); - vm.$store.state.currentActivityView = leftSidebarViews.review.name; + it('does not create an instance', () => { + expect(createInstanceSpy).not.toHaveBeenCalled(); + expect(createDiffInstanceSpy).not.toHaveBeenCalled(); + }); + }); - return vm.$nextTick(); + describe('createEditorInstance', () => { + it.each` + viewer | diffInstance + ${viewerTypes.edit} | ${undefined} + ${viewerTypes.diff} | ${true} + ${viewerTypes.mr} | ${true} + `( + 'creates instance of correct type when viewer is $viewer', + async ({ viewer, diffInstance }) => { + await createComponent({ + state: { viewer }, }); + const isDiff = () => { + return diffInstance ? { isDiff: true } : {}; + }; + expect(createInstanceSpy).toHaveBeenCalledWith(expect.objectContaining(isDiff())); + expect(createDiffInstanceSpy).toHaveBeenCalledTimes((diffInstance && 1) || 0); + }, + ); - it('shows no tabs', () => { - expect(vm.$el.querySelectorAll('.ide-mode-tabs .nav-links a')).toHaveLength(0); + it('installs the WebIDE extension', async () => { + const extensionSpy = jest.spyOn(EditorLite, 'instanceApplyExtension'); + await createComponent(); + expect(extensionSpy).toHaveBeenCalled(); + Reflect.ownKeys(EditorWebIdeExtension.prototype) + .filter((fn) => fn !== 'constructor') + .forEach((fn) => { + expect(vm.editor[fn]).toBe(EditorWebIdeExtension.prototype[fn]); }); - }); }); + }); - describe('when open file is binary and not raw', () => { - beforeEach((done) => { - vm.file.name = 'file.dat'; - vm.file.content = '🐱'; // non-ascii binary content - jest.spyOn(vm.editor, 'createInstance').mockImplementation(); - jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation(); - - vm.$nextTick(done); - }); - - it('does not render the IDE', () => { - expect(vm.shouldHideEditor).toBeTruthy(); - }); - - it('does not call createInstance', async () => { - // Mirror the act's in the `createEditorInstance` - vm.createEditorInstance(); - - await vm.$nextTick(); + describe('setupEditor', () => { + beforeEach(async () => { + await createComponent(); + }); - expect(vm.editor.createInstance).not.toHaveBeenCalled(); - expect(vm.editor.createDiffInstance).not.toHaveBeenCalled(); - }); + it('creates new model on load', () => { + // We always create two models per file to be able to build a diff of changes + expect(createModelSpy).toHaveBeenCalledTimes(2); + // The model with the most recent changes is the last one + const [content] = createModelSpy.mock.calls[1]; + expect(content).toBe(defaultFileProps.content); }); - describe('createEditorInstance', () => { - it('calls createInstance when viewer is editor', (done) => { - jest.spyOn(vm.editor, 'createInstance').mockImplementation(); + it('does not create a new model on subsequent calls to setupEditor and re-uses the already-existing model', () => { + const existingModel = vm.model; + createModelSpy.mockClear(); - vm.createEditorInstance(); + vm.setupEditor(); - vm.$nextTick(() => { - expect(vm.editor.createInstance).toHaveBeenCalled(); + expect(createModelSpy).not.toHaveBeenCalled(); + expect(vm.model).toBe(existingModel); + }); - done(); - }); - }); + it('adds callback methods', () => { + jest.spyOn(vm.editor, 'onPositionChange'); + jest.spyOn(vm.model, 'onChange'); + jest.spyOn(vm.model, 'updateOptions'); - it('calls createDiffInstance when viewer is diff', (done) => { - vm.$store.state.viewer = 'diff'; + vm.setupEditor(); - jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation(); + expect(vm.editor.onPositionChange).toHaveBeenCalledTimes(1); + expect(vm.model.onChange).toHaveBeenCalledTimes(1); + expect(vm.model.updateOptions).toHaveBeenCalledWith(vm.rules); + }); - vm.createEditorInstance(); + it('updates state with the value of the model', () => { + const newContent = 'As Gregor Samsa\n awoke one morning\n'; + vm.model.setValue(newContent); - vm.$nextTick(() => { - expect(vm.editor.createDiffInstance).toHaveBeenCalled(); + vm.setupEditor(); - done(); - }); - }); + expect(vm.file.content).toBe(newContent); + }); - it('calls createDiffInstance when viewer is a merge request diff', (done) => { - vm.$store.state.viewer = 'mrdiff'; + it('sets head model as staged file', () => { + vm.modelManager.dispose(); + const addModelSpy = jest.spyOn(ModelManager.prototype, 'addModel'); - jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation(); + vm.$store.state.stagedFiles.push({ ...vm.file, key: 'staged' }); + vm.file.staged = true; + vm.file.key = `unstaged-${vm.file.key}`; - vm.createEditorInstance(); + vm.setupEditor(); - vm.$nextTick(() => { - expect(vm.editor.createDiffInstance).toHaveBeenCalled(); + expect(addModelSpy).toHaveBeenCalledWith(vm.file, vm.$store.state.stagedFiles[0]); + }); + }); - done(); - }); - }); + describe('editor updateDimensions', () => { + let updateDimensionsSpy; + let updateDiffViewSpy; + beforeEach(async () => { + await createComponent(); + updateDimensionsSpy = jest.spyOn(vm.editor, 'updateDimensions'); + updateDiffViewSpy = jest.spyOn(vm.editor, 'updateDiffView').mockImplementation(); }); - describe('setupEditor', () => { - it('creates new model', () => { - jest.spyOn(vm.editor, 'createModel'); + it('calls updateDimensions only when panelResizing is false', async () => { + expect(updateDimensionsSpy).not.toHaveBeenCalled(); + expect(updateDiffViewSpy).not.toHaveBeenCalled(); + expect(vm.$store.state.panelResizing).toBe(false); // default value - Editor.editorInstance.modelManager.dispose(); + vm.$store.state.panelResizing = true; + await vm.$nextTick(); - vm.setupEditor(); + expect(updateDimensionsSpy).not.toHaveBeenCalled(); + expect(updateDiffViewSpy).not.toHaveBeenCalled(); - expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, null); - expect(vm.model).not.toBeNull(); - }); + vm.$store.state.panelResizing = false; + await vm.$nextTick(); - it('attaches model to editor', () => { - jest.spyOn(vm.editor, 'attachModel'); + expect(updateDimensionsSpy).toHaveBeenCalledTimes(1); + expect(updateDiffViewSpy).toHaveBeenCalledTimes(1); - Editor.editorInstance.modelManager.dispose(); + vm.$store.state.panelResizing = true; + await vm.$nextTick(); - vm.setupEditor(); + expect(updateDimensionsSpy).toHaveBeenCalledTimes(1); + expect(updateDiffViewSpy).toHaveBeenCalledTimes(1); + }); - expect(vm.editor.attachModel).toHaveBeenCalledWith(vm.model); - }); + it('calls updateDimensions when rightPane is toggled', async () => { + expect(updateDimensionsSpy).not.toHaveBeenCalled(); + expect(updateDiffViewSpy).not.toHaveBeenCalled(); + expect(vm.$store.state.rightPane.isOpen).toBe(false); // default value - it('attaches model to merge request editor', () => { - vm.$store.state.viewer = 'mrdiff'; - vm.file.mrChange = true; - jest.spyOn(vm.editor, 'attachMergeRequestModel').mockImplementation(); + vm.$store.state.rightPane.isOpen = true; + await vm.$nextTick(); - Editor.editorInstance.modelManager.dispose(); + expect(updateDimensionsSpy).toHaveBeenCalledTimes(1); + expect(updateDiffViewSpy).toHaveBeenCalledTimes(1); - vm.setupEditor(); + vm.$store.state.rightPane.isOpen = false; + await vm.$nextTick(); - expect(vm.editor.attachMergeRequestModel).toHaveBeenCalledWith(vm.model); - }); + expect(updateDimensionsSpy).toHaveBeenCalledTimes(2); + expect(updateDiffViewSpy).toHaveBeenCalledTimes(2); + }); + }); - it('does not attach model to merge request editor when not a MR change', () => { - vm.$store.state.viewer = 'mrdiff'; - vm.file.mrChange = false; - jest.spyOn(vm.editor, 'attachMergeRequestModel').mockImplementation(); + describe('editor tabs', () => { + beforeEach(async () => { + await createComponent(); + }); - Editor.editorInstance.modelManager.dispose(); + it.each` + mode | isVisible + ${'edit'} | ${true} + ${'review'} | ${false} + ${'commit'} | ${false} + `('tabs in $mode are $isVisible', async ({ mode, isVisible } = {}) => { + vm.$store.state.currentActivityView = leftSidebarViews[mode].name; - vm.setupEditor(); + await vm.$nextTick(); + expect(wrapper.find('.nav-links').exists()).toBe(isVisible); + }); + }); - expect(vm.editor.attachMergeRequestModel).not.toHaveBeenCalledWith(vm.model); + describe('files in preview mode', () => { + let updateDimensionsSpy; + const changeViewMode = (viewMode) => + vm.$store.dispatch('editor/updateFileEditor', { + path: vm.file.path, + data: { viewMode }, }); - it('adds callback methods', () => { - jest.spyOn(vm.editor, 'onPositionChange'); - - Editor.editorInstance.modelManager.dispose(); - - vm.setupEditor(); - - expect(vm.editor.onPositionChange).toHaveBeenCalled(); - expect(vm.model.events.size).toBe(2); + beforeEach(async () => { + await createComponent({ + activeFile: dummyFile.markdown, }); - it('updates state with the value of the model', () => { - vm.model.setValue('testing 1234\n'); - - vm.setupEditor(); - - expect(vm.file.content).toBe('testing 1234\n'); - }); + updateDimensionsSpy = jest.spyOn(vm.editor, 'updateDimensions'); - it('sets head model as staged file', () => { - jest.spyOn(vm.editor, 'createModel'); + changeViewMode(FILE_VIEW_MODE_PREVIEW); + await vm.$nextTick(); + }); - Editor.editorInstance.modelManager.dispose(); + it('do not show the editor', () => { + expect(vm.showEditor).toBe(false); + expect(findEditor().isVisible()).toBe(false); + }); - vm.$store.state.stagedFiles.push({ ...vm.file, key: 'staged' }); - vm.file.staged = true; - vm.file.key = `unstaged-${vm.file.key}`; + it('updates dimensions when switching view back to edit', async () => { + expect(updateDimensionsSpy).not.toHaveBeenCalled(); - vm.setupEditor(); + changeViewMode(FILE_VIEW_MODE_EDITOR); + await vm.$nextTick(); - expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, vm.$store.state.stagedFiles[0]); - }); + expect(updateDimensionsSpy).toHaveBeenCalled(); }); + }); - describe('editor updateDimensions', () => { - beforeEach(() => { - jest.spyOn(vm.editor, 'updateDimensions'); - jest.spyOn(vm.editor, 'updateDiffView').mockImplementation(); - }); - - it('calls updateDimensions when panelResizing is false', (done) => { - vm.$store.state.panelResizing = true; - - vm.$nextTick() - .then(() => { - vm.$store.state.panelResizing = false; - }) - .then(vm.$nextTick) - .then(() => { - expect(vm.editor.updateDimensions).toHaveBeenCalled(); - expect(vm.editor.updateDiffView).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); - }); - - it('does not call updateDimensions when panelResizing is true', (done) => { - vm.$store.state.panelResizing = true; + describe('initEditor', () => { + const hideEditorAndRunFn = async () => { + jest.clearAllMocks(); + jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true); - vm.$nextTick(() => { - expect(vm.editor.updateDimensions).not.toHaveBeenCalled(); - expect(vm.editor.updateDiffView).not.toHaveBeenCalled(); + vm.initEditor(); + await vm.$nextTick(); + }; - done(); - }); + it('does not fetch file information for temp entries', async () => { + await createComponent({ + activeFile: createActiveFile(), }); - it('calls updateDimensions when rightPane is opened', (done) => { - vm.$store.state.rightPane.isOpen = true; - - vm.$nextTick(() => { - expect(vm.editor.updateDimensions).toHaveBeenCalled(); - expect(vm.editor.updateDiffView).toHaveBeenCalled(); - - done(); - }); - }); + expect(vm.getFileData).not.toHaveBeenCalled(); }); - describe('show tabs', () => { - it('shows tabs in edit mode', () => { - expect(vm.$el.querySelector('.nav-links')).not.toBe(null); + it('is being initialised for files without content even if shouldHideEditor is `true`', async () => { + await createComponent({ + activeFile: dummyFile.empty, }); - it('hides tabs in review mode', (done) => { - vm.$store.state.currentActivityView = leftSidebarViews.review.name; + await hideEditorAndRunFn(); - vm.$nextTick(() => { - expect(vm.$el.querySelector('.nav-links')).toBe(null); + expect(vm.getFileData).toHaveBeenCalled(); + expect(vm.getRawFileData).toHaveBeenCalled(); + }); - done(); - }); + it('does not initialize editor for files already with content when shouldHideEditor is `true`', async () => { + await createComponent({ + activeFile: createActiveFile(), }); - it('hides tabs in commit mode', (done) => { - vm.$store.state.currentActivityView = leftSidebarViews.commit.name; + await hideEditorAndRunFn(); - vm.$nextTick(() => { - expect(vm.$el.querySelector('.nav-links')).toBe(null); + expect(vm.getFileData).not.toHaveBeenCalled(); + expect(vm.getRawFileData).not.toHaveBeenCalled(); + expect(createInstanceSpy).not.toHaveBeenCalled(); + }); + }); - done(); - }); + describe('updates on file changes', () => { + beforeEach(async () => { + await createComponent({ + activeFile: createActiveFile({ + content: 'foo', // need to prevent full cycle of initEditor + }), }); + jest.spyOn(vm, 'initEditor').mockImplementation(); }); - describe('when files view mode is preview', () => { - beforeEach((done) => { - jest.spyOn(vm.editor, 'updateDimensions').mockImplementation(); - changeViewMode(FILE_VIEW_MODE_PREVIEW); - vm.file.name = 'myfile.md'; - vm.file.content = 'hello world'; + it('calls removePendingTab when old file is pending', async () => { + jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true); + jest.spyOn(vm, 'removePendingTab').mockImplementation(); - vm.$nextTick(done); - }); + const origFile = vm.file; + vm.file.pending = true; + await vm.$nextTick(); - it('should hide editor', () => { - expect(vm.showEditor).toBe(false); - expect(findEditor()).toHaveCss({ display: 'none' }); + wrapper.setProps({ + file: file('testing'), }); + vm.file.content = 'foo'; // need to prevent full cycle of initEditor + await vm.$nextTick(); - describe('when file view mode changes to editor', () => { - it('should update dimensions', () => { - changeViewMode(FILE_VIEW_MODE_EDITOR); - - return vm.$nextTick().then(() => { - expect(vm.editor.updateDimensions).toHaveBeenCalled(); - }); - }); - }); + expect(vm.removePendingTab).toHaveBeenCalledWith(origFile); }); - describe('initEditor', () => { - beforeEach(() => { - vm.file.tempFile = false; - jest.spyOn(vm.editor, 'createInstance').mockImplementation(); - jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true); - }); + it('does not call initEditor if the file did not change', async () => { + Vue.set(vm, 'file', vm.file); + await vm.$nextTick(); - it('does not fetch file information for temp entries', (done) => { - vm.file.tempFile = true; - - vm.initEditor(); - vm.$nextTick() - .then(() => { - expect(vm.getFileData).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); - }); - - it('is being initialised for files without content even if shouldHideEditor is `true`', (done) => { - vm.file.content = ''; - vm.file.raw = ''; + expect(vm.initEditor).not.toHaveBeenCalled(); + }); - vm.initEditor(); + it('calls initEditor when file key is changed', async () => { + expect(vm.initEditor).not.toHaveBeenCalled(); - vm.$nextTick() - .then(() => { - expect(vm.getFileData).toHaveBeenCalled(); - expect(vm.getRawFileData).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + wrapper.setProps({ + file: { + ...vm.file, + key: 'new', + }, }); + await vm.$nextTick(); - it('does not initialize editor for files already with content', (done) => { - vm.file.content = 'foo'; - - vm.initEditor(); - vm.$nextTick() - .then(() => { - expect(vm.getFileData).not.toHaveBeenCalled(); - expect(vm.getRawFileData).not.toHaveBeenCalled(); - expect(vm.editor.createInstance).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); - }); + expect(vm.initEditor).toHaveBeenCalled(); + }); + }); + + describe('populates editor with the fetched content', () => { + const createRemoteFile = (name) => ({ + ...file(name), + tmpFile: false, }); - describe('updates on file changes', () => { - beforeEach(() => { - jest.spyOn(vm, 'initEditor').mockImplementation(); - }); + beforeEach(async () => { + await createComponent(); + vm.getRawFileData.mockRestore(); + }); - it('calls removePendingTab when old file is pending', (done) => { - jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true); - jest.spyOn(vm, 'removePendingTab').mockImplementation(); + it('after switching viewer from edit to diff', async () => { + const f = createRemoteFile('newFile'); + Vue.set(vm.$store.state.entries, f.path, f); - vm.file.pending = true; + jest.spyOn(service, 'getRawFileData').mockImplementation(async () => { + expect(vm.file.loading).toBe(true); - vm.$nextTick() - .then(() => { - vm.file = file('testing'); - vm.file.content = 'foo'; // need to prevent full cycle of initEditor + // switching from edit to diff mode usually triggers editor initialization + vm.$store.state.viewer = viewerTypes.diff; - return vm.$nextTick(); - }) - .then(() => { - expect(vm.removePendingTab).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + // we delay returning the file to make sure editor doesn't initialize before we fetch file content + await waitUsingRealTimer(30); + return 'rawFileData123\n'; }); - it('does not call initEditor if the file did not change', (done) => { - Vue.set(vm, 'file', vm.file); - - vm.$nextTick() - .then(() => { - expect(vm.initEditor).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + wrapper.setProps({ + file: f, }); - it('calls initEditor when file key is changed', (done) => { - expect(vm.initEditor).not.toHaveBeenCalled(); + await waitForEditorSetup(); + expect(vm.model.getModel().getValue()).toBe('rawFileData123\n'); + }); - Vue.set(vm, 'file', { - ...vm.file, - key: 'new', + it('after opening multiple files at the same time', async () => { + const fileA = createRemoteFile('fileA'); + const aContent = 'fileA-rawContent\n'; + const bContent = 'fileB-rawContent\n'; + const fileB = createRemoteFile('fileB'); + Vue.set(vm.$store.state.entries, fileA.path, fileA); + Vue.set(vm.$store.state.entries, fileB.path, fileB); + + jest + .spyOn(service, 'getRawFileData') + .mockImplementation(async () => { + // opening fileB while the content of fileA is still being fetched + wrapper.setProps({ + file: fileB, + }); + return aContent; + }) + .mockImplementationOnce(async () => { + // we delay returning fileB content to make sure the editor doesn't initialize prematurely + await waitUsingRealTimer(30); + return bContent; }); - vm.$nextTick() - .then(() => { - expect(vm.initEditor).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + wrapper.setProps({ + file: fileA, }); - }); - describe('populates editor with the fetched content', () => { - beforeEach(() => { - vm.getRawFileData.mockRestore(); - }); + await waitForEditorSetup(); + expect(vm.model.getModel().getValue()).toBe(bContent); + }); + }); - const createRemoteFile = (name) => ({ - ...file(name), - tmpFile: false, + describe('onPaste', () => { + const setFileName = (name) => + createActiveFile({ + content: 'hello world\n', + name, + path: `foo/${name}`, + key: 'new', }); - it('after switching viewer from edit to diff', async () => { - jest.spyOn(service, 'getRawFileData').mockImplementation(async () => { - expect(vm.file.loading).toBe(true); - - // switching from edit to diff mode usually triggers editor initialization - store.state.viewer = viewerTypes.diff; + const pasteImage = () => { + window.dispatchEvent( + Object.assign(new Event('paste'), { + clipboardData: { + files: [new File(['foo'], 'foo.png', { type: 'image/png' })], + }, + }), + ); + }; - // we delay returning the file to make sure editor doesn't initialize before we fetch file content - await waitUsingRealTimer(30); - return 'rawFileData123\n'; + const watchState = (watched) => + new Promise((resolve) => { + const unwatch = vm.$store.watch(watched, () => { + unwatch(); + resolve(); }); - - const f = createRemoteFile('newFile'); - Vue.set(store.state.entries, f.path, f); - - vm.file = f; - - await waitForEditorSetup(); - expect(vm.model.getModel().getValue()).toBe('rawFileData123\n'); }); - it('after opening multiple files at the same time', async () => { - const fileA = createRemoteFile('fileA'); - const fileB = createRemoteFile('fileB'); - Vue.set(store.state.entries, fileA.path, fileA); - Vue.set(store.state.entries, fileB.path, fileB); - - jest - .spyOn(service, 'getRawFileData') - .mockImplementationOnce(async () => { - // opening fileB while the content of fileA is still being fetched - vm.file = fileB; - return 'fileA-rawContent\n'; - }) - .mockImplementationOnce(async () => { - // we delay returning fileB content to make sure the editor doesn't initialize prematurely - await waitUsingRealTimer(30); - return 'fileB-rawContent\n'; - }); + // Pasting an image does a lot of things like using the FileReader API, + // so, waitForPromises isn't very reliable (and causes a flaky spec) + // Read more about state.watch: https://vuex.vuejs.org/api/#watch + const waitForFileContentChange = () => watchState((s) => s.entries['foo/bar.md'].content); - vm.file = fileA; - - await waitForEditorSetup(); - expect(vm.model.getModel().getValue()).toBe('fileB-rawContent\n'); + beforeEach(async () => { + await createComponent({ + state: { + trees: { + 'gitlab-org/gitlab': { tree: [] }, + }, + currentProjectId: 'gitlab-org', + currentBranchId: 'gitlab', + }, + activeFile: setFileName('bar.md'), }); - }); - - describe('onPaste', () => { - const setFileName = (name) => { - Vue.set(vm, 'file', { - ...vm.file, - content: 'hello world\n', - name, - path: `foo/${name}`, - key: 'new', - }); - vm.$store.state.entries[vm.file.path] = vm.file; - }; + vm.setupEditor(); - const pasteImage = () => { - window.dispatchEvent( - Object.assign(new Event('paste'), { - clipboardData: { - files: [new File(['foo'], 'foo.png', { type: 'image/png' })], - }, - }), - ); - }; - - const watchState = (watched) => - new Promise((resolve) => { - const unwatch = vm.$store.watch(watched, () => { - unwatch(); - resolve(); - }); - }); + await waitForPromises(); + // set cursor to line 2, column 1 + vm.editor.setSelection(new Range(2, 1, 2, 1)); + vm.editor.focus(); - // Pasting an image does a lot of things like using the FileReader API, - // so, waitForPromises isn't very reliable (and causes a flaky spec) - // Read more about state.watch: https://vuex.vuejs.org/api/#watch - const waitForFileContentChange = () => watchState((s) => s.entries['foo/bar.md'].content); - - beforeEach(() => { - setFileName('bar.md'); - - vm.$store.state.trees['gitlab-org/gitlab'] = { tree: [] }; - vm.$store.state.currentProjectId = 'gitlab-org'; - vm.$store.state.currentBranchId = 'gitlab'; - - // create a new model each time, otherwise tests conflict with each other - // because of same model being used in multiple tests - Editor.editorInstance.modelManager.dispose(); - vm.setupEditor(); + jest.spyOn(vm.editor, 'hasTextFocus').mockReturnValue(true); + }); - return waitForPromises().then(() => { - // set cursor to line 2, column 1 - vm.editor.instance.setSelection(new Range(2, 1, 2, 1)); - vm.editor.instance.focus(); + it('adds an image entry to the same folder for a pasted image in a markdown file', async () => { + pasteImage(); - jest.spyOn(vm.editor.instance, 'hasTextFocus').mockReturnValue(true); - }); + await waitForFileContentChange(); + expect(vm.$store.state.entries['foo/foo.png']).toMatchObject({ + path: 'foo/foo.png', + type: 'blob', + content: 'Zm9v', + rawPath: 'data:image/png;base64,Zm9v', }); + }); - it('adds an image entry to the same folder for a pasted image in a markdown file', () => { - pasteImage(); - - return waitForFileContentChange().then(() => { - expect(vm.$store.state.entries['foo/foo.png']).toMatchObject({ - path: 'foo/foo.png', - type: 'blob', - content: 'Zm9v', - rawPath: 'data:image/png;base64,Zm9v', - }); - }); - }); + it("adds a markdown image tag to the file's contents", async () => { + pasteImage(); - it("adds a markdown image tag to the file's contents", () => { - pasteImage(); + await waitForFileContentChange(); + expect(vm.file.content).toBe('hello world\n![foo.png](./foo.png)'); + }); - return waitForFileContentChange().then(() => { - expect(vm.file.content).toBe('hello world\n![foo.png](./foo.png)'); - }); + it("does not add file to state or set markdown image syntax if the file isn't markdown", async () => { + wrapper.setProps({ + file: setFileName('myfile.txt'), }); + pasteImage(); - it("does not add file to state or set markdown image syntax if the file isn't markdown", () => { - setFileName('myfile.txt'); - pasteImage(); - - return waitForPromises().then(() => { - expect(vm.$store.state.entries['foo/foo.png']).toBeUndefined(); - expect(vm.file.content).toBe('hello world\n'); - }); - }); + await waitForPromises(); + expect(vm.$store.state.entries['foo/foo.png']).toBeUndefined(); + expect(vm.file.content).toBe('hello world\n'); }); }); describe('fetchEditorconfigRules', () => { - beforeEach(() => { - exampleConfigs.forEach(({ path, content }) => { - store.state.entries[path] = { ...file(), path, content }; - }); - }); - it.each(exampleFiles)( 'does not fetch content from remote for .editorconfig files present locally (case %#)', - ({ path, monacoRules }) => { - createOpenFile(path); - createComponent(); - - return waitForEditorSetup().then(() => { - expect(vm.rules).toEqual(monacoRules); - expect(vm.model.options).toMatchObject(monacoRules); - expect(vm.getFileData).not.toHaveBeenCalled(); - expect(vm.getRawFileData).not.toHaveBeenCalled(); + async ({ path, monacoRules }) => { + await createComponent({ + state: { + entries: (() => { + const res = {}; + exampleConfigs.forEach(({ path: configPath, content }) => { + res[configPath] = { ...file(), path: configPath, content }; + }); + return res; + })(), + }, + activeFile: createActiveFile({ + path, + key: path, + name: 'myfile.txt', + content: 'hello world', + }), }); + + expect(vm.rules).toEqual(monacoRules); + expect(vm.model.options).toMatchObject(monacoRules); + expect(vm.getFileData).not.toHaveBeenCalled(); + expect(vm.getRawFileData).not.toHaveBeenCalled(); }, ); - it('fetches content from remote for .editorconfig files not available locally', () => { - exampleConfigs.forEach(({ path }) => { - delete store.state.entries[path].content; - delete store.state.entries[path].raw; + it('fetches content from remote for .editorconfig files not available locally', async () => { + const activeFile = createActiveFile({ + path: 'foo/bar/baz/test/my_spec.js', + key: 'foo/bar/baz/test/my_spec.js', + name: 'myfile.txt', + content: 'hello world', + }); + + const expectations = [ + 'foo/bar/baz/.editorconfig', + 'foo/bar/.editorconfig', + 'foo/.editorconfig', + '.editorconfig', + ]; + + await createComponent({ + state: { + entries: (() => { + const res = { + [activeFile.path]: activeFile, + }; + exampleConfigs.forEach(({ path: configPath }) => { + const f = { ...file(), path: configPath }; + delete f.content; + delete f.raw; + res[configPath] = f; + }); + return res; + })(), + }, + activeFile, }); - // Include a "test" directory which does not exist in store. This one should be skipped. - createOpenFile('foo/bar/baz/test/my_spec.js'); - createComponent(); - - return waitForEditorSetup().then(() => { - expect(vm.getFileData.mock.calls.map(([args]) => args)).toEqual([ - { makeFileActive: false, path: 'foo/bar/baz/.editorconfig' }, - { makeFileActive: false, path: 'foo/bar/.editorconfig' }, - { makeFileActive: false, path: 'foo/.editorconfig' }, - { makeFileActive: false, path: '.editorconfig' }, - ]); - expect(vm.getRawFileData.mock.calls.map(([args]) => args)).toEqual([ - { path: 'foo/bar/baz/.editorconfig' }, - { path: 'foo/bar/.editorconfig' }, - { path: 'foo/.editorconfig' }, - { path: '.editorconfig' }, - ]); - }); + expect(service.getFileData.mock.calls.map(([args]) => args)).toEqual( + expectations.map((expectation) => expect.stringContaining(expectation)), + ); + expect(service.getRawFileData.mock.calls.map(([args]) => args)).toEqual( + expectations.map((expectation) => expect.objectContaining({ path: expectation })), + ); }); }); }); diff --git a/spec/frontend/ide/components/repo_tab_spec.js b/spec/frontend/ide/components/repo_tab_spec.js index b39a488b034..95d52e8f7a9 100644 --- a/spec/frontend/ide/components/repo_tab_spec.js +++ b/spec/frontend/ide/components/repo_tab_spec.js @@ -1,5 +1,7 @@ +import { GlTab } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import { stubComponent } from 'helpers/stub_component'; import RepoTab from '~/ide/components/repo_tab.vue'; import { createRouter } from '~/ide/ide_router'; import { createStore } from '~/ide/stores'; @@ -8,16 +10,25 @@ import { file } from '../helpers'; const localVue = createLocalVue(); localVue.use(Vuex); +const GlTabStub = stubComponent(GlTab, { + template: '<li><slot name="title" /></li>', +}); + describe('RepoTab', () => { let wrapper; let store; let router; + const findTab = () => wrapper.find(GlTabStub); + function createComponent(propsData) { wrapper = mount(RepoTab, { localVue, store, propsData, + stubs: { + GlTab: GlTabStub, + }, }); } @@ -55,7 +66,7 @@ describe('RepoTab', () => { jest.spyOn(wrapper.vm, 'openPendingTab').mockImplementation(() => {}); - await wrapper.trigger('click'); + await findTab().vm.$emit('click'); expect(wrapper.vm.openPendingTab).not.toHaveBeenCalled(); }); @@ -67,7 +78,7 @@ describe('RepoTab', () => { jest.spyOn(wrapper.vm, 'clickFile').mockImplementation(() => {}); - wrapper.trigger('click'); + findTab().vm.$emit('click'); expect(wrapper.vm.clickFile).toHaveBeenCalledWith(wrapper.vm.tab); }); @@ -91,11 +102,11 @@ describe('RepoTab', () => { tab, }); - await wrapper.trigger('mouseover'); + await findTab().vm.$emit('mouseover'); expect(wrapper.find('.file-modified').exists()).toBe(false); - await wrapper.trigger('mouseout'); + await findTab().vm.$emit('mouseout'); expect(wrapper.find('.file-modified').exists()).toBe(true); }); diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js index 678d58cba34..3503834e24b 100644 --- a/spec/frontend/ide/services/index_spec.js +++ b/spec/frontend/ide/services/index_spec.js @@ -1,7 +1,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql'; import Api from '~/api'; -import getUserPermissions from '~/ide/queries/getUserPermissions.query.graphql'; import services from '~/ide/services'; import { query } from '~/ide/services/gql'; import { escapeFileUrl } from '~/lib/utils/url_utility'; @@ -228,7 +228,7 @@ describe('IDE services', () => { expect(response).toEqual({ data: { ...projectData, ...gqlProjectData } }); expect(Api.project).toHaveBeenCalledWith(TEST_PROJECT_ID); expect(query).toHaveBeenCalledWith({ - query: getUserPermissions, + query: getIdeProject, variables: { projectPath: TEST_PROJECT_ID, }, diff --git a/spec/frontend/ide/stores/getters_spec.js b/spec/frontend/ide/stores/getters_spec.js index 450f5592026..6b66c87e205 100644 --- a/spec/frontend/ide/stores/getters_spec.js +++ b/spec/frontend/ide/stores/getters_spec.js @@ -1,7 +1,17 @@ import { TEST_HOST } from 'helpers/test_constants'; +import { + DEFAULT_PERMISSIONS, + PERMISSION_PUSH_CODE, + PUSH_RULE_REJECT_UNSIGNED_COMMITS, +} from '~/ide/constants'; +import { + MSG_CANNOT_PUSH_CODE, + MSG_CANNOT_PUSH_CODE_SHORT, + MSG_CANNOT_PUSH_UNSIGNED, + MSG_CANNOT_PUSH_UNSIGNED_SHORT, +} from '~/ide/messages'; import { createStore } from '~/ide/stores'; import * as getters from '~/ide/stores/getters'; -import { DEFAULT_PERMISSIONS } from '../../../../app/assets/javascripts/ide/constants'; import { file } from '../helpers'; const TEST_PROJECT_ID = 'test_project'; @@ -385,22 +395,23 @@ describe('IDE store getters', () => { ); }); - describe('findProjectPermissions', () => { - it('returns false if project not found', () => { - expect(localStore.getters.findProjectPermissions(TEST_PROJECT_ID)).toEqual( - DEFAULT_PERMISSIONS, - ); + describe.each` + getterName | projectField | defaultValue + ${'findProjectPermissions'} | ${'userPermissions'} | ${DEFAULT_PERMISSIONS} + ${'findPushRules'} | ${'pushRules'} | ${{}} + `('$getterName', ({ getterName, projectField, defaultValue }) => { + const callGetter = (...args) => localStore.getters[getterName](...args); + + it('returns default if project not found', () => { + expect(callGetter(TEST_PROJECT_ID)).toEqual(defaultValue); }); - it('finds permission in given project', () => { - const userPermissions = { - readMergeRequest: true, - createMergeRequestsIn: false, - }; + it('finds field in given project', () => { + const obj = { test: 'foo' }; - localState.projects[TEST_PROJECT_ID] = { userPermissions }; + localState.projects[TEST_PROJECT_ID] = { [projectField]: obj }; - expect(localStore.getters.findProjectPermissions(TEST_PROJECT_ID)).toBe(userPermissions); + expect(callGetter(TEST_PROJECT_ID)).toBe(obj); }); }); @@ -408,7 +419,6 @@ describe('IDE store getters', () => { getterName | permissionKey ${'canReadMergeRequests'} | ${'readMergeRequest'} ${'canCreateMergeRequests'} | ${'createMergeRequestIn'} - ${'canPushCode'} | ${'pushCode'} `('$getterName', ({ getterName, permissionKey }) => { it.each([true, false])('finds permission for current project (%s)', (val) => { localState.projects[TEST_PROJECT_ID] = { @@ -422,6 +432,38 @@ describe('IDE store getters', () => { }); }); + describe('canPushCodeStatus', () => { + it.each` + pushCode | rejectUnsignedCommits | expected + ${true} | ${false} | ${{ isAllowed: true, message: '', messageShort: '' }} + ${false} | ${false} | ${{ isAllowed: false, message: MSG_CANNOT_PUSH_CODE, messageShort: MSG_CANNOT_PUSH_CODE_SHORT }} + ${false} | ${true} | ${{ isAllowed: false, message: MSG_CANNOT_PUSH_UNSIGNED, messageShort: MSG_CANNOT_PUSH_UNSIGNED_SHORT }} + `( + 'with pushCode="$pushCode" and rejectUnsignedCommits="$rejectUnsignedCommits"', + ({ pushCode, rejectUnsignedCommits, expected }) => { + localState.projects[TEST_PROJECT_ID] = { + pushRules: { + [PUSH_RULE_REJECT_UNSIGNED_COMMITS]: rejectUnsignedCommits, + }, + userPermissions: { + [PERMISSION_PUSH_CODE]: pushCode, + }, + }; + localState.currentProjectId = TEST_PROJECT_ID; + + expect(localStore.getters.canPushCodeStatus).toEqual(expected); + }, + ); + }); + + describe('canPushCode', () => { + it.each([true, false])('with canPushCodeStatus.isAllowed = $s', (isAllowed) => { + const canPushCodeStatus = { isAllowed }; + + expect(getters.canPushCode({}, { canPushCodeStatus })).toBe(isAllowed); + }); + }); + describe('entryExists', () => { beforeEach(() => { localState.entries = { diff --git a/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js index cdef4b1ee62..7a83136e785 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js @@ -1,10 +1,15 @@ -import { GlButton, GlLink, GlFormInput } from '@gitlab/ui'; +import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { STATUSES } from '~/import_entities/constants'; import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue'; -import Select2Select from '~/vue_shared/components/select2_select.vue'; +import groupQuery from '~/import_entities/import_groups/graphql/queries/group.query.graphql'; import { availableNamespacesFixture } from '../graphql/fixtures'; +Vue.use(VueApollo); + const getFakeGroup = (status) => ({ web_url: 'https://fake.host/', full_path: 'fake_group_1', @@ -17,8 +22,12 @@ const getFakeGroup = (status) => ({ status, }); +const EXISTING_GROUP_TARGET_NAMESPACE = 'existing-group'; +const EXISTING_GROUP_PATH = 'existing-path'; + describe('import table row', () => { let wrapper; + let apolloProvider; let group; const findByText = (cmp, text) => { @@ -26,12 +35,27 @@ describe('import table row', () => { }; const findImportButton = () => findByText(GlButton, 'Import'); const findNameInput = () => wrapper.find(GlFormInput); - const findNamespaceDropdown = () => wrapper.find(Select2Select); + const findNamespaceDropdown = () => wrapper.find(GlDropdown); const createComponent = (props) => { + apolloProvider = createMockApollo([ + [ + groupQuery, + ({ fullPath }) => { + const existingGroup = + fullPath === `${EXISTING_GROUP_TARGET_NAMESPACE}/${EXISTING_GROUP_PATH}` + ? { id: 1 } + : null; + return Promise.resolve({ data: { existingGroup } }); + }, + ], + ]); + wrapper = shallowMount(ImportTableRow, { + apolloProvider, propsData: { availableNamespaces: availableNamespacesFixture, + groupPathRegex: /.*/, ...props, }, }); @@ -49,15 +73,24 @@ describe('import table row', () => { }); it.each` - selector | sourceEvent | payload | event - ${findNamespaceDropdown} | ${'input'} | ${'demo'} | ${'update-target-namespace'} - ${findNameInput} | ${'input'} | ${'demo'} | ${'update-new-name'} - ${findImportButton} | ${'click'} | ${undefined} | ${'import-group'} + selector | sourceEvent | payload | event + ${findNameInput} | ${'input'} | ${'demo'} | ${'update-new-name'} + ${findImportButton} | ${'click'} | ${undefined} | ${'import-group'} `('invokes $event', ({ selector, sourceEvent, payload, event }) => { selector().vm.$emit(sourceEvent, payload); expect(wrapper.emitted(event)).toBeDefined(); expect(wrapper.emitted(event)[0][0]).toBe(payload); }); + + it('emits update-target-namespace when dropdown option is clicked', () => { + const dropdownItem = findNamespaceDropdown().findAllComponents(GlDropdownItem).at(2); + const dropdownItemText = dropdownItem.text(); + + dropdownItem.vm.$emit('click'); + + expect(wrapper.emitted('update-target-namespace')).toBeDefined(); + expect(wrapper.emitted('update-target-namespace')[0][0]).toBe(dropdownItemText); + }); }); describe('when entity status is NONE', () => { @@ -75,6 +108,34 @@ describe('import table row', () => { }); }); + it('renders only no parent option if available namespaces list is empty', () => { + createComponent({ + group: getFakeGroup(STATUSES.NONE), + availableNamespaces: [], + }); + + const items = findNamespaceDropdown() + .findAllComponents(GlDropdownItem) + .wrappers.map((w) => w.text()); + + expect(items[0]).toBe('No parent'); + expect(items).toHaveLength(1); + }); + + it('renders both no parent option and available namespaces list when available namespaces list is not empty', () => { + createComponent({ + group: getFakeGroup(STATUSES.NONE), + availableNamespaces: availableNamespacesFixture, + }); + + const [firstItem, ...rest] = findNamespaceDropdown() + .findAllComponents(GlDropdownItem) + .wrappers.map((w) => w.text()); + + expect(firstItem).toBe('No parent'); + expect(rest).toHaveLength(availableNamespacesFixture.length); + }); + describe('when entity status is SCHEDULING', () => { beforeEach(() => { group = getFakeGroup(STATUSES.SCHEDULING); @@ -109,4 +170,38 @@ describe('import table row', () => { expect(findByText(GlLink, TARGET_LINK).exists()).toBe(true); }); }); + + describe('validations', () => { + it('Reports invalid group name when name is not matching regex', () => { + createComponent({ + group: { + ...getFakeGroup(STATUSES.NONE), + import_target: { + target_namespace: 'root', + new_name: 'very`bad`name', + }, + }, + groupPathRegex: /^[a-zA-Z]+$/, + }); + + expect(wrapper.text()).toContain('Please choose a group URL with no special characters.'); + }); + + it('Reports invalid group name if group already exists', async () => { + createComponent({ + group: { + ...getFakeGroup(STATUSES.NONE), + import_target: { + target_namespace: EXISTING_GROUP_TARGET_NAMESPACE, + new_name: EXISTING_GROUP_PATH, + }, + }, + }); + + jest.runOnlyPendingTimers(); + await nextTick(); + + expect(wrapper.text()).toContain('Name already exists.'); + }); + }); }); diff --git a/spec/frontend/import_entities/import_groups/components/import_table_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_spec.js index dd734782169..496c5cda7c7 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js @@ -1,7 +1,15 @@ -import { GlEmptyState, GlLoadingIcon, GlSearchBoxByClick, GlSprintf } from '@gitlab/ui'; +import { + GlEmptyState, + GlLoadingIcon, + GlSearchBoxByClick, + GlSprintf, + GlDropdown, + GlDropdownItem, +} from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import { STATUSES } from '~/import_entities/constants'; import ImportTable from '~/import_entities/import_groups/components/import_table.vue'; @@ -16,13 +24,25 @@ import { availableNamespacesFixture, generateFakeEntry } from '../graphql/fixtur const localVue = createLocalVue(); localVue.use(VueApollo); +const GlDropdownStub = stubComponent(GlDropdown, { + template: '<div><h1 ref="text"><slot name="button-content"></slot></h1><slot></slot></div>', +}); + describe('import table', () => { let wrapper; let apolloProvider; + const SOURCE_URL = 'https://demo.host'; const FAKE_GROUP = generateFakeEntry({ id: 1, status: STATUSES.NONE }); + const FAKE_GROUPS = [ + generateFakeEntry({ id: 1, status: STATUSES.NONE }), + generateFakeEntry({ id: 2, status: STATUSES.FINISHED }), + ]; const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 }; + const findPaginationDropdown = () => wrapper.findComponent(GlDropdown); + const findPaginationDropdownText = () => findPaginationDropdown().find({ ref: 'text' }).text(); + const createComponent = ({ bulkImportSourceGroups }) => { apolloProvider = createMockApollo([], { Query: { @@ -38,10 +58,12 @@ describe('import table', () => { wrapper = shallowMount(ImportTable, { propsData: { - sourceUrl: 'https://demo.host', + groupPathRegex: /.*/, + sourceUrl: SOURCE_URL, }, stubs: { GlSprintf, + GlDropdown: GlDropdownStub, }, localVue, apolloProvider, @@ -80,14 +102,10 @@ describe('import table', () => { }); await waitForPromises(); - expect(wrapper.find(GlEmptyState).props().title).toBe('No groups available for import'); + expect(wrapper.find(GlEmptyState).props().title).toBe('You have no groups to import'); }); it('renders import row for each group in response', async () => { - const FAKE_GROUPS = [ - generateFakeEntry({ id: 1, status: STATUSES.NONE }), - generateFakeEntry({ id: 2, status: STATUSES.FINISHED }), - ]; createComponent({ bulkImportSourceGroups: () => ({ nodes: FAKE_GROUPS, @@ -151,6 +169,20 @@ describe('import table', () => { expect(wrapper.find(PaginationLinks).props().pageInfo).toStrictEqual(FAKE_PAGE_INFO); }); + it('renders pagination dropdown', () => { + expect(findPaginationDropdown().exists()).toBe(true); + }); + + it('updates page size when selected in Dropdown', async () => { + const otherOption = wrapper.findAllComponents(GlDropdownItem).at(1); + expect(otherOption.text()).toMatchInterpolatedText('50 items per page'); + + otherOption.vm.$emit('click'); + await waitForPromises(); + + expect(findPaginationDropdownText()).toMatchInterpolatedText('50 items per page'); + }); + it('updates page when page change is requested', async () => { const REQUESTED_PAGE = 2; wrapper.find(PaginationLinks).props().change(REQUESTED_PAGE); @@ -178,7 +210,7 @@ describe('import table', () => { wrapper.find(PaginationLinks).props().change(REQUESTED_PAGE); await waitForPromises(); - expect(wrapper.text()).toContain('Showing 21-21 of 38'); + expect(wrapper.text()).toContain('Showing 21-21 of 38 groups from'); }); }); @@ -224,7 +256,7 @@ describe('import table', () => { findFilterInput().vm.$emit('submit', FILTER_VALUE); await waitForPromises(); - expect(wrapper.text()).toContain('Showing 1-1 of 40 groups matching filter "foo"'); + expect(wrapper.text()).toContain('Showing 1-1 of 40 groups matching filter "foo" from'); }); it('properly resets filter in graphql query when search box is cleared', async () => { diff --git a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js index 4d3d2c41bbe..1feff861c1e 100644 --- a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js +++ b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js @@ -2,6 +2,7 @@ import { InMemoryCache } from 'apollo-cache-inmemory'; import MockAdapter from 'axios-mock-adapter'; import { createMockClient } from 'mock-apollo-client'; import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; import { STATUSES } from '~/import_entities/constants'; import { clientTypenames, @@ -18,6 +19,7 @@ import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import { statusEndpointFixture, availableNamespacesFixture } from './fixtures'; +jest.mock('~/flash'); jest.mock('~/import_entities/import_groups/graphql/services/status_poller', () => ({ StatusPoller: jest.fn().mockImplementation(function mock() { this.startPolling = jest.fn(); @@ -35,15 +37,19 @@ describe('Bulk import resolvers', () => { let axiosMockAdapter; let client; - beforeEach(() => { - axiosMockAdapter = new MockAdapter(axios); - client = createMockClient({ + const createClient = (extraResolverArgs) => { + return createMockClient({ cache: new InMemoryCache({ fragmentMatcher: { match: () => true }, addTypename: false, }), - resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS }), + resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS, ...extraResolverArgs }), }); + }; + + beforeEach(() => { + axiosMockAdapter = new MockAdapter(axios); + client = createClient(); }); afterEach(() => { @@ -82,6 +88,44 @@ describe('Bulk import resolvers', () => { .reply(httpStatus.OK, availableNamespacesFixture); }); + it('respects cached import state when provided by group manager', async () => { + const FAKE_STATUS = 'DEMO_STATUS'; + const FAKE_IMPORT_TARGET = {}; + const TARGET_INDEX = 0; + + const clientWithMockedManager = createClient({ + GroupsManager: jest.fn().mockImplementation(() => ({ + getImportStateFromStorageByGroupId(groupId) { + if (groupId === statusEndpointFixture.importable_data[TARGET_INDEX].id) { + return { + status: FAKE_STATUS, + importTarget: FAKE_IMPORT_TARGET, + }; + } + + return null; + }, + })), + }); + + const clientResponse = await clientWithMockedManager.query({ + query: bulkImportSourceGroupsQuery, + }); + const clientResults = clientResponse.data.bulkImportSourceGroups.nodes; + + expect(clientResults[TARGET_INDEX].import_target).toBe(FAKE_IMPORT_TARGET); + expect(clientResults[TARGET_INDEX].status).toBe(FAKE_STATUS); + }); + + it('populates each result instance with empty import_target when there are no available namespaces', async () => { + axiosMockAdapter.onGet(FAKE_ENDPOINTS.availableNamespaces).reply(httpStatus.OK, []); + + const response = await client.query({ query: bulkImportSourceGroupsQuery }); + results = response.data.bulkImportSourceGroups.nodes; + + expect(results.every((r) => r.import_target.target_namespace === '')).toBe(true); + }); + describe('when called', () => { beforeEach(async () => { const response = await client.query({ query: bulkImportSourceGroupsQuery }); @@ -220,14 +264,14 @@ describe('Bulk import resolvers', () => { expect(intermediateResults[0].status).toBe(STATUSES.SCHEDULING); }); - it('sets group status to STARTED when request completes', async () => { + it('sets import status to CREATED when request completes', async () => { axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 }); await client.mutate({ mutation: importGroupMutation, variables: { sourceGroupId: GROUP_ID }, }); - expect(results[0].status).toBe(STATUSES.STARTED); + expect(results[0].status).toBe(STATUSES.CREATED); }); it('resets status to NONE if request fails', async () => { @@ -245,6 +289,40 @@ describe('Bulk import resolvers', () => { expect(results[0].status).toBe(STATUSES.NONE); }); + + it('shows default error message when server error is not provided', async () => { + axiosMockAdapter + .onPost(FAKE_ENDPOINTS.createBulkImport) + .reply(httpStatus.INTERNAL_SERVER_ERROR); + + client + .mutate({ + mutation: importGroupMutation, + variables: { sourceGroupId: GROUP_ID }, + }) + .catch(() => {}); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ message: 'Importing the group failed' }); + }); + + it('shows provided error message when error is included in backend response', async () => { + const CUSTOM_MESSAGE = 'custom message'; + + axiosMockAdapter + .onPost(FAKE_ENDPOINTS.createBulkImport) + .reply(httpStatus.INTERNAL_SERVER_ERROR, { error: CUSTOM_MESSAGE }); + + client + .mutate({ + mutation: importGroupMutation, + variables: { sourceGroupId: GROUP_ID }, + }) + .catch(() => {}); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ message: CUSTOM_MESSAGE }); + }); }); }); }); diff --git a/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js b/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js index ca987ab3ab4..5baa201906a 100644 --- a/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js +++ b/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js @@ -1,11 +1,17 @@ import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory'; import ImportSourceGroupFragment from '~/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql'; -import { SourceGroupsManager } from '~/import_entities/import_groups/graphql/services/source_groups_manager'; +import { + KEY, + SourceGroupsManager, +} from '~/import_entities/import_groups/graphql/services/source_groups_manager'; + +const FAKE_SOURCE_URL = 'http://demo.host'; describe('SourceGroupsManager', () => { let manager; let client; + let storage; const getFakeGroup = () => ({ __typename: clientTypenames.BulkImportSourceGroup, @@ -17,8 +23,53 @@ describe('SourceGroupsManager', () => { readFragment: jest.fn(), writeFragment: jest.fn(), }; + storage = { + getItem: jest.fn(), + setItem: jest.fn(), + }; + + manager = new SourceGroupsManager({ client, storage, sourceUrl: FAKE_SOURCE_URL }); + }); + + describe('storage management', () => { + const IMPORT_ID = 1; + const IMPORT_TARGET = { destination_name: 'demo', destination_namespace: 'foo' }; + const STATUS = 'FAKE_STATUS'; + const FAKE_GROUP = { id: 1, import_target: IMPORT_TARGET, status: STATUS }; + + it('loads state from storage on creation', () => { + expect(storage.getItem).toHaveBeenCalledWith(KEY); + }); + + it('saves to storage when import is starting', () => { + manager.startImport({ + importId: IMPORT_ID, + group: FAKE_GROUP, + }); + const storedObject = JSON.parse(storage.setItem.mock.calls[0][1]); + expect(Object.values(storedObject)[0]).toStrictEqual({ + id: FAKE_GROUP.id, + importTarget: IMPORT_TARGET, + status: STATUS, + }); + }); - manager = new SourceGroupsManager({ client }); + it('saves to storage when import status is updated', () => { + const CHANGED_STATUS = 'changed'; + + manager.startImport({ + importId: IMPORT_ID, + group: FAKE_GROUP, + }); + + manager.setImportStatusByImportId(IMPORT_ID, CHANGED_STATUS); + const storedObject = JSON.parse(storage.setItem.mock.calls[1][1]); + expect(Object.values(storedObject)[0]).toStrictEqual({ + id: FAKE_GROUP.id, + importTarget: IMPORT_TARGET, + status: CHANGED_STATUS, + }); + }); }); it('finds item by group id', () => { diff --git a/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js b/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js index a5fc4e18a02..0d4809971ae 100644 --- a/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js +++ b/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js @@ -2,7 +2,6 @@ import MockAdapter from 'axios-mock-adapter'; import Visibility from 'visibilityjs'; import createFlash from '~/flash'; import { STATUSES } from '~/import_entities/constants'; -import { SourceGroupsManager } from '~/import_entities/import_groups/graphql/services/source_groups_manager'; import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller'; import axios from '~/lib/utils/axios_utils'; import Poll from '~/lib/utils/poll'; @@ -18,24 +17,21 @@ jest.mock('~/import_entities/import_groups/graphql/services/source_groups_manage })); const FAKE_POLL_PATH = '/fake/poll/path'; -const CLIENT_MOCK = {}; describe('Bulk import status poller', () => { let poller; let mockAdapter; + let groupManager; const getPollHistory = () => mockAdapter.history.get.filter((x) => x.url === FAKE_POLL_PATH); beforeEach(() => { mockAdapter = new MockAdapter(axios); mockAdapter.onGet(FAKE_POLL_PATH).reply(200, {}); - poller = new StatusPoller({ client: CLIENT_MOCK, pollPath: FAKE_POLL_PATH }); - }); - - it('creates source group manager with proper client', () => { - expect(SourceGroupsManager.mock.calls).toHaveLength(1); - const [[{ client }]] = SourceGroupsManager.mock.calls; - expect(client).toBe(CLIENT_MOCK); + groupManager = { + setImportStatusByImportId: jest.fn(), + }; + poller = new StatusPoller({ groupManager, pollPath: FAKE_POLL_PATH }); }); it('creates poller with proper config', () => { @@ -100,14 +96,9 @@ describe('Bulk import status poller', () => { it('when success response arrives updates relevant group status', () => { const FAKE_ID = 5; const [[pollConfig]] = Poll.mock.calls; - const [managerInstance] = SourceGroupsManager.mock.instances; - managerInstance.findByImportId.mockReturnValue({ id: FAKE_ID }); pollConfig.successCallback({ data: [{ id: FAKE_ID, status_name: STATUSES.FINISHED }] }); - expect(managerInstance.setImportStatus).toHaveBeenCalledWith( - expect.objectContaining({ id: FAKE_ID }), - STATUSES.FINISHED, - ); + expect(groupManager.setImportStatusByImportId).toHaveBeenCalledWith(FAKE_ID, STATUSES.FINISHED); }); }); diff --git a/spec/frontend/incidents/mocks/incidents.json b/spec/frontend/incidents/mocks/incidents.json index 07c87a5d43d..78783a0dce5 100644 --- a/spec/frontend/incidents/mocks/incidents.json +++ b/spec/frontend/incidents/mocks/incidents.json @@ -1,7 +1,7 @@ [ { "iid": "15", - "title": "New: Incident", + "title": "New: Alert", "createdAt": "2020-06-03T15:46:08Z", "assignees": {}, "state": "opened", diff --git a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap index 82d7f691efd..5796b3fa44e 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap @@ -35,7 +35,7 @@ exports[`Alert integration settings form default state should match the default Incident template (optional) <gl-link-stub - href="/help/user/project/description_templates#creating-issue-templates" + href="/help/user/project/description_templates#create-an-issue-template" target="_blank" > <gl-icon-stub @@ -78,7 +78,7 @@ exports[`Alert integration settings form default state should match the default > <gl-form-checkbox-stub> <span> - Send a separate email notification to Developers. + Send a single email notification to Owners and Maintainers for new alerts. </span> </gl-form-checkbox-stub> </gl-form-group-stub> diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index df855674804..c015fd0b9e0 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -207,27 +207,6 @@ describe('IntegrationForm', () => { expect(findJiraTriggerFields().exists()).toBe(true); }); - - describe('featureFlag jiraIssuesIntegration is false', () => { - it('does not render JiraIssuesFields', () => { - createComponent({ - customStateProps: { type: 'jira' }, - featureFlags: { jiraIssuesIntegration: false }, - }); - - expect(findJiraIssuesFields().exists()).toBe(false); - }); - }); - - describe('featureFlag jiraIssuesIntegration is true', () => { - it('renders JiraIssuesFields', () => { - createComponent({ - customStateProps: { type: 'jira' }, - featureFlags: { jiraIssuesIntegration: true }, - }); - expect(findJiraIssuesFields().exists()).toBe(true); - }); - }); }); describe('triggerEvents is present', () => { diff --git a/spec/frontend/integrations/integration_settings_form_spec.js b/spec/frontend/integrations/integration_settings_form_spec.js index 348b942703f..cbb2ef380ba 100644 --- a/spec/frontend/integrations/integration_settings_form_spec.js +++ b/spec/frontend/integrations/integration_settings_form_spec.js @@ -7,7 +7,6 @@ jest.mock('~/vue_shared/plugins/global_toast'); describe('IntegrationSettingsForm', () => { const FIXTURE = 'services/edit_service.html'; - preloadFixtures(FIXTURE); beforeEach(() => { loadFixtures(FIXTURE); diff --git a/spec/frontend/invite_members/components/group_select_spec.js b/spec/frontend/invite_members/components/group_select_spec.js new file mode 100644 index 00000000000..2a6985de136 --- /dev/null +++ b/spec/frontend/invite_members/components/group_select_spec.js @@ -0,0 +1,90 @@ +import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import Api from '~/api'; +import GroupSelect from '~/invite_members/components/group_select.vue'; + +const createComponent = () => { + return mount(GroupSelect, {}); +}; + +const group1 = { id: 1, full_name: 'Group One' }; +const group2 = { id: 2, full_name: 'Group Two' }; +const allGroups = [group1, group2]; + +describe('GroupSelect', () => { + let wrapper; + + beforeEach(() => { + jest.spyOn(Api, 'groups').mockResolvedValue(allGroups); + + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownToggle = () => findDropdown().find('button[aria-haspopup="true"]'); + const findDropdownItemByText = (text) => + wrapper + .findAllComponents(GlDropdownItem) + .wrappers.find((dropdownItemWrapper) => dropdownItemWrapper.text() === text); + + it('renders GlSearchBoxByType with default attributes', () => { + expect(findSearchBoxByType().exists()).toBe(true); + expect(findSearchBoxByType().vm.$attrs).toMatchObject({ + placeholder: 'Search groups', + }); + }); + + describe('when user types in the search input', () => { + let resolveApiRequest; + + beforeEach(() => { + jest.spyOn(Api, 'groups').mockImplementation( + () => + new Promise((resolve) => { + resolveApiRequest = resolve; + }), + ); + + findSearchBoxByType().vm.$emit('input', group1.name); + }); + + it('calls the API', () => { + resolveApiRequest({ data: allGroups }); + + expect(Api.groups).toHaveBeenCalledWith(group1.name, { + active: true, + exclude_internal: true, + }); + }); + + it('displays loading icon while waiting for API call to resolve', async () => { + expect(findSearchBoxByType().props('isLoading')).toBe(true); + + resolveApiRequest({ data: allGroups }); + await waitForPromises(); + + expect(findSearchBoxByType().props('isLoading')).toBe(false); + }); + }); + + describe('when group is selected from the dropdown', () => { + beforeEach(() => { + findDropdownItemByText(group1.full_name).vm.$emit('click'); + }); + + it('emits `input` event used by `v-model`', () => { + expect(wrapper.emitted('input')[0][0].id).toEqual(group1.id); + }); + + it('sets dropdown toggle text to selected item', () => { + expect(findDropdownToggle().text()).toBe(group1.full_name); + }); + }); +}); diff --git a/spec/frontend/invite_members/components/invite_group_trigger_spec.js b/spec/frontend/invite_members/components/invite_group_trigger_spec.js new file mode 100644 index 00000000000..cb9967ebe8c --- /dev/null +++ b/spec/frontend/invite_members/components/invite_group_trigger_spec.js @@ -0,0 +1,50 @@ +import { GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import InviteGroupTrigger from '~/invite_members/components/invite_group_trigger.vue'; +import eventHub from '~/invite_members/event_hub'; + +const displayText = 'Invite a group'; + +const createComponent = (props = {}) => { + return mount(InviteGroupTrigger, { + propsData: { + displayText, + ...props, + }, + }); +}; + +describe('InviteGroupTrigger', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findButton = () => wrapper.findComponent(GlButton); + + describe('displayText', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('includes the correct displayText for the link', () => { + expect(findButton().text()).toBe(displayText); + }); + }); + + describe('when button is clicked', () => { + beforeEach(() => { + eventHub.$emit = jest.fn(); + + wrapper = createComponent(); + + findButton().trigger('click'); + }); + + it('emits event that triggers opening the modal', () => { + expect(eventHub.$emit).toHaveBeenLastCalledWith('openModal', { inviteeType: 'group' }); + }); + }); +}); diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js index e310a00133c..5ca5d855038 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -6,10 +6,11 @@ import Api from '~/api'; import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; const id = '1'; -const name = 'testgroup'; +const name = 'test name'; const isProject = false; +const inviteeType = 'members'; const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }; -const defaultAccessLevel = '10'; +const defaultAccessLevel = 10; const helpLink = 'https://example.com'; const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' }; @@ -20,16 +21,19 @@ const user3 = { username: 'one_2', avatar_url: '', }; +const sharedGroup = { id: '981' }; -const createComponent = (data = {}) => { +const createComponent = (data = {}, props = {}) => { return shallowMount(InviteMembersModal, { propsData: { id, name, isProject, + inviteeType, accessLevels, defaultAccessLevel, helpLink, + ...props, }, data() { return data; @@ -46,6 +50,22 @@ const createComponent = (data = {}) => { }); }; +const createInviteMembersToProjectWrapper = () => { + return createComponent({ inviteeType: 'members' }, { isProject: true }); +}; + +const createInviteMembersToGroupWrapper = () => { + return createComponent({ inviteeType: 'members' }, { isProject: false }); +}; + +const createInviteGroupToProjectWrapper = () => { + return createComponent({ inviteeType: 'group' }, { isProject: true }); +}; + +const createInviteGroupToGroupWrapper = () => { + return createComponent({ inviteeType: 'group' }, { isProject: false }); +}; + describe('InviteMembersModal', () => { let wrapper; @@ -54,12 +74,13 @@ describe('InviteMembersModal', () => { wrapper = null; }); - const findDropdown = () => wrapper.find(GlDropdown); - const findDropdownItems = () => findDropdown().findAll(GlDropdownItem); - const findDatepicker = () => wrapper.find(GlDatepicker); - const findLink = () => wrapper.find(GlLink); - const findCancelButton = () => wrapper.find({ ref: 'cancelButton' }); - const findInviteButton = () => wrapper.find({ ref: 'inviteButton' }); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem); + const findDatepicker = () => wrapper.findComponent(GlDatepicker); + const findLink = () => wrapper.findComponent(GlLink); + const findIntroText = () => wrapper.find({ ref: 'introText' }).text(); + const findCancelButton = () => wrapper.findComponent({ ref: 'cancelButton' }); + const findInviteButton = () => wrapper.findComponent({ ref: 'inviteButton' }); const clickInviteButton = () => findInviteButton().vm.$emit('click'); describe('rendering the modal', () => { @@ -68,7 +89,7 @@ describe('InviteMembersModal', () => { }); it('renders the modal with the correct title', () => { - expect(wrapper.find(GlModal).props('title')).toBe('Invite team members'); + expect(wrapper.findComponent(GlModal).props('title')).toBe('Invite team members'); }); it('renders the Cancel button text correctly', () => { @@ -102,21 +123,60 @@ describe('InviteMembersModal', () => { }); }); + describe('displaying the correct introText', () => { + describe('when inviting to a project', () => { + describe('when inviting members', () => { + it('includes the correct invitee, type, and formatted name', () => { + wrapper = createInviteMembersToProjectWrapper(); + + expect(findIntroText()).toBe("You're inviting members to the test name project."); + }); + }); + + describe('when sharing with a group', () => { + it('includes the correct invitee, type, and formatted name', () => { + wrapper = createInviteGroupToProjectWrapper(); + + expect(findIntroText()).toBe("You're inviting a group to the test name project."); + }); + }); + }); + + describe('when inviting to a group', () => { + describe('when inviting members', () => { + it('includes the correct invitee, type, and formatted name', () => { + wrapper = createInviteMembersToGroupWrapper(); + + expect(findIntroText()).toBe("You're inviting members to the test name group."); + }); + }); + + describe('when sharing with a group', () => { + it('includes the correct invitee, type, and formatted name', () => { + wrapper = createInviteGroupToGroupWrapper(); + + expect(findIntroText()).toBe("You're inviting a group to the test name group."); + }); + }); + }); + }); + describe('submitting the invite form', () => { const apiErrorMessage = 'Member already exists'; describe('when inviting an existing user to group by user ID', () => { const postData = { user_id: '1', - access_level: '10', + access_level: defaultAccessLevel, expires_at: undefined, format: 'json', }; describe('when invites are sent successfully', () => { beforeEach(() => { - wrapper = createComponent({ newUsersToInvite: [user1] }); + wrapper = createInviteMembersToGroupWrapper(); + wrapper.setData({ newUsersToInvite: [user1] }); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); @@ -178,7 +238,7 @@ describe('InviteMembersModal', () => { describe('when inviting a new user by email address', () => { const postData = { - access_level: '10', + access_level: defaultAccessLevel, expires_at: undefined, email: 'email@example.com', format: 'json', @@ -227,7 +287,7 @@ describe('InviteMembersModal', () => { describe('when inviting members and non-members in same click', () => { const postData = { - access_level: '10', + access_level: defaultAccessLevel, expires_at: undefined, format: 'json', }; @@ -283,5 +343,58 @@ describe('InviteMembersModal', () => { }); }); }); + + describe('when inviting a group to share', () => { + describe('when sharing the group is successful', () => { + const groupPostData = { + group_id: sharedGroup.id, + group_access: defaultAccessLevel, + expires_at: undefined, + format: 'json', + }; + + beforeEach(() => { + wrapper = createComponent({ groupToBeSharedWith: sharedGroup }); + + wrapper.setData({ inviteeType: 'group' }); + wrapper.vm.$toast = { show: jest.fn() }; + jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData }); + jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); + + clickInviteButton(); + }); + + it('calls Api groupShareWithGroup with the correct params', () => { + expect(Api.groupShareWithGroup).toHaveBeenCalledWith(id, groupPostData); + }); + + it('displays the successful toastMessage', () => { + expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + }); + }); + + describe('when sharing the group fails', () => { + beforeEach(() => { + wrapper = createComponent({ groupToBeSharedWith: sharedGroup }); + + wrapper.setData({ inviteeType: 'group' }); + wrapper.vm.$toast = { show: jest.fn() }; + + jest + .spyOn(Api, 'groupShareWithGroup') + .mockRejectedValue({ response: { data: { success: false } } }); + + jest.spyOn(wrapper.vm, 'showToastMessageError'); + + clickInviteButton(); + }); + + it('displays the generic error toastMessage', async () => { + await waitForPromises(); + + expect(wrapper.vm.showToastMessageError).toHaveBeenCalled(); + }); + }); + }); }); }); diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js index 18d6662d2d4..f362aace1df 100644 --- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js +++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js @@ -1,9 +1,8 @@ -import { GlIcon, GlLink } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; const displayText = 'Invite team members'; -const icon = 'plus'; const createComponent = (props = {}) => { return shallowMount(InviteMembersTrigger, { @@ -23,36 +22,14 @@ describe('InviteMembersTrigger', () => { }); describe('displayText', () => { - const findLink = () => wrapper.find(GlLink); + const findButton = () => wrapper.findComponent(GlButton); beforeEach(() => { wrapper = createComponent(); }); - it('includes the correct displayText for the link', () => { - expect(findLink().text()).toBe(displayText); - }); - }); - - describe('icon', () => { - const findIcon = () => wrapper.find(GlIcon); - - it('includes the correct icon when an icon is sent', () => { - wrapper = createComponent({ icon }); - - expect(findIcon().attributes('name')).toBe(icon); - }); - - it('does not include an icon when icon is not sent', () => { - wrapper = createComponent(); - - expect(findIcon().exists()).toBe(false); - }); - - it('does not include an icon when empty string is sent', () => { - wrapper = createComponent({ icon: '' }); - - expect(findIcon().exists()).toBe(false); + it('includes the correct displayText for the button', () => { + expect(findButton().text()).toBe(displayText); }); }); }); diff --git a/spec/frontend/invite_members/components/members_token_select_spec.js b/spec/frontend/invite_members/components/members_token_select_spec.js index a945b99bd54..f6e79d3607f 100644 --- a/spec/frontend/invite_members/components/members_token_select_spec.js +++ b/spec/frontend/invite_members/components/members_token_select_spec.js @@ -37,7 +37,7 @@ describe('MembersTokenSelect', () => { wrapper = null; }); - const findTokenSelector = () => wrapper.find(GlTokenSelector); + const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); describe('rendering the token-selector component', () => { it('renders with the correct props', () => { diff --git a/spec/frontend/issuable/components/csv_export_modal_spec.js b/spec/frontend/issuable/components/csv_export_modal_spec.js new file mode 100644 index 00000000000..f46b6f72f05 --- /dev/null +++ b/spec/frontend/issuable/components/csv_export_modal_spec.js @@ -0,0 +1,91 @@ +import { GlModal, GlIcon, GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CsvExportModal from '~/issuable/components/csv_export_modal.vue'; + +describe('CsvExportModal', () => { + let wrapper; + + function createComponent(options = {}) { + const { injectedProperties = {}, props = {} } = options; + return extendedWrapper( + mount(CsvExportModal, { + propsData: { + modalId: 'csv-export-modal', + ...props, + }, + provide: { + issuableType: 'issues', + ...injectedProperties, + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: + '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', + }), + }, + }), + ); + } + + afterEach(() => { + wrapper.destroy(); + }); + + const findModal = () => wrapper.findComponent(GlModal); + const findIcon = () => wrapper.findComponent(GlIcon); + const findButton = () => wrapper.findComponent(GlButton); + + describe('template', () => { + describe.each` + issuableType | modalTitle + ${'issues'} | ${'Export issues'} + ${'merge-requests'} | ${'Export merge requests'} + `('with the issuableType "$issuableType"', ({ issuableType, modalTitle }) => { + beforeEach(() => { + wrapper = createComponent({ injectedProperties: { issuableType } }); + }); + + it('displays the modal title "$modalTitle"', () => { + expect(findModal().text()).toContain(modalTitle); + }); + + it('displays the button with title "$modalTitle"', () => { + expect(findButton().text()).toBe(modalTitle); + }); + }); + + describe('issuable count info text', () => { + it('displays the info text when issuableCount is > -1', () => { + wrapper = createComponent({ injectedProperties: { issuableCount: 10 } }); + expect(wrapper.findByTestId('issuable-count-note').exists()).toBe(true); + expect(wrapper.findByTestId('issuable-count-note').text()).toContain('10 issues selected'); + expect(findIcon().exists()).toBe(true); + }); + + it("doesn't display the info text when issuableCount is -1", () => { + wrapper = createComponent({ injectedProperties: { issuableCount: -1 } }); + expect(wrapper.findByTestId('issuable-count-note').exists()).toBe(false); + }); + }); + + describe('email info text', () => { + it('displays the proper email', () => { + const email = 'admin@example.com'; + wrapper = createComponent({ injectedProperties: { email } }); + expect(findModal().text()).toContain( + `The CSV export will be created in the background. Once finished, it will be sent to ${email} in an attachment.`, + ); + }); + }); + + describe('primary button', () => { + it('passes the exportCsvPath to the button', () => { + const exportCsvPath = '/gitlab-org/gitlab-test/-/issues/export_csv'; + wrapper = createComponent({ injectedProperties: { exportCsvPath } }); + expect(findButton().attributes('href')).toBe(exportCsvPath); + }); + }); + }); +}); diff --git a/spec/frontend/issuable/components/csv_import_export_buttons_spec.js b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js new file mode 100644 index 00000000000..e32bf35b13a --- /dev/null +++ b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js @@ -0,0 +1,187 @@ +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CsvExportModal from '~/issuable/components/csv_export_modal.vue'; +import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; +import CsvImportModal from '~/issuable/components/csv_import_modal.vue'; + +describe('CsvImportExportButtons', () => { + let wrapper; + let glModalDirective; + + function createComponent(injectedProperties = {}) { + glModalDirective = jest.fn(); + return extendedWrapper( + shallowMount(CsvImportExportButtons, { + directives: { + GlTooltip: createMockDirective(), + glModal: { + bind(_, { value }) { + glModalDirective(value); + }, + }, + }, + provide: { + ...injectedProperties, + }, + }), + ); + } + + afterEach(() => { + wrapper.destroy(); + }); + + const findExportCsvButton = () => wrapper.findByTestId('export-csv-button'); + const findImportDropdown = () => wrapper.findByTestId('import-csv-dropdown'); + const findImportCsvButton = () => wrapper.findByTestId('import-csv-dropdown'); + const findImportFromJiraLink = () => wrapper.findByTestId('import-from-jira-link'); + const findExportCsvModal = () => wrapper.findComponent(CsvExportModal); + const findImportCsvModal = () => wrapper.findComponent(CsvImportModal); + + describe('template', () => { + describe('when the showExportButton=true', () => { + beforeEach(() => { + wrapper = createComponent({ showExportButton: true }); + }); + + it('displays the export button', () => { + expect(findExportCsvButton().exists()).toBe(true); + }); + + it('export button has a tooltip', () => { + const tooltip = getBinding(findExportCsvButton().element, 'gl-tooltip'); + + expect(tooltip).toBeDefined(); + expect(tooltip.value).toBe('Export as CSV'); + }); + + it('renders the export modal', () => { + expect(findExportCsvModal().exists()).toBe(true); + }); + + it('opens the export modal', () => { + findExportCsvButton().trigger('click'); + + expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.exportModalId); + }); + }); + + describe('when the showExportButton=false', () => { + beforeEach(() => { + wrapper = createComponent({ showExportButton: false }); + }); + + it('does not display the export button', () => { + expect(findExportCsvButton().exists()).toBe(false); + }); + + it('does not render the export modal', () => { + expect(findExportCsvModal().exists()).toBe(false); + }); + }); + + describe('when the showImportButton=true', () => { + beforeEach(() => { + wrapper = createComponent({ showImportButton: true }); + }); + + it('displays the import dropdown', () => { + expect(findImportDropdown().exists()).toBe(true); + }); + + it('renders the import button', () => { + expect(findImportCsvButton().exists()).toBe(true); + }); + + describe('when showLabel=false', () => { + beforeEach(() => { + wrapper = createComponent({ showImportButton: true, showLabel: false }); + }); + + it('does not have a button text', () => { + expect(findImportCsvButton().props('text')).toBe(null); + }); + + it('import button has a tooltip', () => { + const tooltip = getBinding(findImportDropdown().element, 'gl-tooltip'); + + expect(tooltip).toBeDefined(); + expect(tooltip.value).toBe('Import issues'); + }); + }); + + describe('when showLabel=true', () => { + beforeEach(() => { + wrapper = createComponent({ showImportButton: true, showLabel: true }); + }); + + it('displays a button text', () => { + expect(findImportCsvButton().props('text')).toBe('Import issues'); + }); + + it('import button has no tooltip', () => { + const tooltip = getBinding(findImportDropdown().element, 'gl-tooltip'); + + expect(tooltip.value).toBe(null); + }); + }); + + it('renders the import modal', () => { + expect(findImportCsvModal().exists()).toBe(true); + }); + + it('opens the import modal', () => { + findImportCsvButton().trigger('click'); + + expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.importModalId); + }); + + describe('import from jira link', () => { + const projectImportJiraPath = 'gitlab-org/gitlab-test/-/import/jira'; + + beforeEach(() => { + wrapper = createComponent({ + showImportButton: true, + canEdit: true, + projectImportJiraPath, + }); + }); + + describe('when canEdit=true', () => { + it('renders the import dropdown item', () => { + expect(findImportFromJiraLink().exists()).toBe(true); + }); + + it('passes the proper path to the link', () => { + expect(findImportFromJiraLink().attributes('href')).toBe(projectImportJiraPath); + }); + }); + + describe('when canEdit=false', () => { + beforeEach(() => { + wrapper = createComponent({ showImportButton: true, canEdit: false }); + }); + + it('does not render the import dropdown item', () => { + expect(findImportFromJiraLink().exists()).toBe(false); + }); + }); + }); + }); + + describe('when the showImportButton=false', () => { + beforeEach(() => { + wrapper = createComponent({ showImportButton: false }); + }); + + it('does not display the import dropdown', () => { + expect(findImportDropdown().exists()).toBe(false); + }); + + it('does not render the import modal', () => { + expect(findImportCsvModal().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/issuable/components/csv_import_modal_spec.js b/spec/frontend/issuable/components/csv_import_modal_spec.js new file mode 100644 index 00000000000..ce9d738f77b --- /dev/null +++ b/spec/frontend/issuable/components/csv_import_modal_spec.js @@ -0,0 +1,86 @@ +import { GlModal } from '@gitlab/ui'; +import { getByRole, getByLabelText } from '@testing-library/dom'; +import { mount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CsvImportModal from '~/issuable/components/csv_import_modal.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +describe('CsvImportModal', () => { + let wrapper; + let formSubmitSpy; + + function createComponent(options = {}) { + const { injectedProperties = {}, props = {} } = options; + return extendedWrapper( + mount(CsvImportModal, { + propsData: { + modalId: 'csv-import-modal', + ...props, + }, + provide: { + issuableType: 'issues', + ...injectedProperties, + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: '<div><slot></slot><slot name="modal-footer"></slot></div>', + }), + }, + }), + ); + } + + beforeEach(() => { + formSubmitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findModal = () => wrapper.findComponent(GlModal); + const findPrimaryButton = () => getByRole(wrapper.element, 'button', { name: 'Import issues' }); + const findForm = () => wrapper.findByTestId('import-csv-form'); + const findFileInput = () => getByLabelText(wrapper.element, 'Upload CSV file'); + const findAuthenticityToken = () => new FormData(findForm().element).get('authenticity_token'); + + describe('template', () => { + it('displays modal title', () => { + wrapper = createComponent(); + expect(findModal().text()).toContain('Import issues'); + }); + + it('displays a note about the maximum allowed file size', () => { + const maxAttachmentSize = 500; + wrapper = createComponent({ injectedProperties: { maxAttachmentSize } }); + expect(findModal().text()).toContain(`The maximum file size allowed is ${maxAttachmentSize}`); + }); + + describe('form', () => { + const importCsvIssuesPath = 'gitlab-org/gitlab-test/-/issues/import_csv'; + + beforeEach(() => { + wrapper = createComponent({ injectedProperties: { importCsvIssuesPath } }); + }); + + it('displays the form with the correct action and inputs', () => { + expect(findForm().exists()).toBe(true); + expect(findForm().attributes('action')).toBe(importCsvIssuesPath); + expect(findAuthenticityToken()).toBe('mock-csrf-token'); + expect(findFileInput()).toExist(); + }); + + it('displays the correct primary button action text', () => { + expect(findPrimaryButton()).toExist(); + }); + + it('submits the form when the primary action is clicked', async () => { + findPrimaryButton().click(); + + expect(formSubmitSpy).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/issuable_list/components/issuable_item_spec.js index 987acf559e3..7281d2fde1d 100644 --- a/spec/frontend/issuable_list/components/issuable_item_spec.js +++ b/spec/frontend/issuable_list/components/issuable_item_spec.js @@ -202,7 +202,7 @@ describe('IssuableItem', () => { describe('labelTarget', () => { it('returns target string for a provided label param when `enableLabelPermalinks` is true', () => { expect(wrapper.vm.labelTarget(mockRegularLabel)).toBe( - '?label_name%5B%5D=Documentation%20Update', + '?label_name[]=Documentation%20Update', ); }); @@ -294,7 +294,17 @@ describe('IssuableItem', () => { expect(confidentialEl.exists()).toBe(true); expect(confidentialEl.props('name')).toBe('eye-slash'); - expect(confidentialEl.attributes('title')).toBe('Confidential'); + expect(confidentialEl.attributes()).toMatchObject({ + title: 'Confidential', + arialabel: 'Confidential', + }); + }); + + it('renders task status', () => { + const taskStatus = wrapper.find('[data-testid="task-status"]'); + const expected = `${mockIssuable.taskCompletionStatus.completedCount} of ${mockIssuable.taskCompletionStatus.count} tasks completed`; + + expect(taskStatus.text()).toBe(expected); }); it('renders issuable reference', () => { diff --git a/spec/frontend/issuable_list/mock_data.js b/spec/frontend/issuable_list/mock_data.js index e19a337473a..33ffd60bf95 100644 --- a/spec/frontend/issuable_list/mock_data.js +++ b/spec/frontend/issuable_list/mock_data.js @@ -53,6 +53,10 @@ export const mockIssuable = { }, assignees: [mockAuthor], userDiscussionsCount: 2, + taskCompletionStatus: { + count: 2, + completedCount: 1, + }, }; export const mockIssuables = [ diff --git a/spec/frontend/issuable_show/components/issuable_body_spec.js b/spec/frontend/issuable_show/components/issuable_body_spec.js index bf166bea1e5..6fa298ca3f2 100644 --- a/spec/frontend/issuable_show/components/issuable_body_spec.js +++ b/spec/frontend/issuable_show/components/issuable_body_spec.js @@ -6,11 +6,13 @@ import IssuableBody from '~/issuable_show/components/issuable_body.vue'; import IssuableDescription from '~/issuable_show/components/issuable_description.vue'; import IssuableEditForm from '~/issuable_show/components/issuable_edit_form.vue'; import IssuableTitle from '~/issuable_show/components/issuable_title.vue'; +import TaskList from '~/task_list'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { mockIssuableShowProps, mockIssuable } from '../mock_data'; jest.mock('~/autosave'); +jest.mock('~/flash'); const issuableBodyProps = { ...mockIssuableShowProps, @@ -80,6 +82,75 @@ describe('IssuableBody', () => { }); }); + describe('watchers', () => { + describe('editFormVisible', () => { + it('calls initTaskList in nextTick', async () => { + jest.spyOn(wrapper.vm, 'initTaskList'); + wrapper.setProps({ + editFormVisible: true, + }); + + await wrapper.vm.$nextTick(); + + wrapper.setProps({ + editFormVisible: false, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.initTaskList).toHaveBeenCalled(); + }); + }); + }); + + describe('mounted', () => { + it('initializes TaskList instance when enabledEdit and enableTaskList props are true', () => { + expect(wrapper.vm.taskList instanceof TaskList).toBe(true); + expect(wrapper.vm.taskList).toMatchObject({ + dataType: 'issue', + fieldName: 'description', + lockVersion: issuableBodyProps.taskListLockVersion, + selector: '.js-detail-page-description', + onSuccess: expect.any(Function), + onError: expect.any(Function), + }); + }); + + it('does not initialize TaskList instance when either enabledEdit or enableTaskList prop is false', () => { + const wrapperNoTaskList = createComponent({ + ...issuableBodyProps, + enableTaskList: false, + }); + + expect(wrapperNoTaskList.vm.taskList).not.toBeDefined(); + + wrapperNoTaskList.destroy(); + }); + }); + + describe('methods', () => { + describe('handleTaskListUpdateSuccess', () => { + it('emits `task-list-update-success` event on component', () => { + const updatedIssuable = { + foo: 'bar', + }; + + wrapper.vm.handleTaskListUpdateSuccess(updatedIssuable); + + expect(wrapper.emitted('task-list-update-success')).toBeTruthy(); + expect(wrapper.emitted('task-list-update-success')[0]).toEqual([updatedIssuable]); + }); + }); + + describe('handleTaskListUpdateFailure', () => { + it('emits `task-list-update-failure` event on component', () => { + wrapper.vm.handleTaskListUpdateFailure(); + + expect(wrapper.emitted('task-list-update-failure')).toBeTruthy(); + }); + }); + }); + describe('template', () => { it('renders issuable-title component', () => { const titleEl = wrapper.find(IssuableTitle); diff --git a/spec/frontend/issuable_show/components/issuable_description_spec.js b/spec/frontend/issuable_show/components/issuable_description_spec.js index 29ecce1002d..1058e5decfd 100644 --- a/spec/frontend/issuable_show/components/issuable_description_spec.js +++ b/spec/frontend/issuable_show/components/issuable_description_spec.js @@ -5,9 +5,14 @@ import IssuableDescription from '~/issuable_show/components/issuable_description import { mockIssuable } from '../mock_data'; -const createComponent = (issuable = mockIssuable) => +const createComponent = ({ + issuable = mockIssuable, + enableTaskList = true, + canEdit = true, + taskListUpdatePath = `${mockIssuable.webUrl}.json`, +} = {}) => shallowMount(IssuableDescription, { - propsData: { issuable }, + propsData: { issuable, enableTaskList, canEdit, taskListUpdatePath }, }); describe('IssuableDescription', () => { @@ -38,4 +43,27 @@ describe('IssuableDescription', () => { }); }); }); + + describe('templates', () => { + it('renders container element with class `js-task-list-container` when canEdit and enableTaskList props are true', () => { + expect(wrapper.classes()).toContain('js-task-list-container'); + }); + + it('renders container element without class `js-task-list-container` when canEdit and enableTaskList props are true', () => { + const wrapperNoTaskList = createComponent({ + enableTaskList: false, + }); + + expect(wrapperNoTaskList.classes()).not.toContain('js-task-list-container'); + + wrapperNoTaskList.destroy(); + }); + + it('renders hidden textarea element when issuable.description is present and enableTaskList prop is true', () => { + const textareaEl = wrapper.find('textarea.gl-display-none.js-task-list-field'); + + expect(textareaEl.exists()).toBe(true); + expect(textareaEl.attributes('data-update-url')).toBe(`${mockIssuable.webUrl}.json`); + }); + }); }); diff --git a/spec/frontend/issuable_show/components/issuable_header_spec.js b/spec/frontend/issuable_show/components/issuable_header_spec.js index 2164caa40a8..b85f2dd1999 100644 --- a/spec/frontend/issuable_show/components/issuable_header_spec.js +++ b/spec/frontend/issuable_show/components/issuable_header_spec.js @@ -119,6 +119,27 @@ describe('IssuableHeader', () => { expect(avatarEl.find(GlAvatarLabeled).find(GlIcon).exists()).toBe(false); }); + it('renders tast status text when `taskCompletionStatus` prop is defined', () => { + let taskStatusEl = wrapper.findByTestId('task-status'); + + expect(taskStatusEl.exists()).toBe(true); + expect(taskStatusEl.text()).toContain('0 of 5 tasks completed'); + + const wrapperSingleTask = createComponent({ + ...issuableHeaderProps, + taskCompletionStatus: { + completedCount: 0, + count: 1, + }, + }); + + taskStatusEl = wrapperSingleTask.findByTestId('task-status'); + + expect(taskStatusEl.text()).toContain('0 of 1 task completed'); + + wrapperSingleTask.destroy(); + }); + it('renders sidebar toggle button', () => { const toggleButtonEl = wrapper.findByTestId('sidebar-toggle'); diff --git a/spec/frontend/issuable_show/components/issuable_show_root_spec.js b/spec/frontend/issuable_show/components/issuable_show_root_spec.js index 3e3778492d2..b4c125f4910 100644 --- a/spec/frontend/issuable_show/components/issuable_show_root_spec.js +++ b/spec/frontend/issuable_show/components/issuable_show_root_spec.js @@ -54,6 +54,7 @@ describe('IssuableShowRoot', () => { editFormVisible, descriptionPreviewPath, descriptionHelpPath, + taskCompletionStatus, } = mockIssuableShowProps; const { blocked, confidential, createdAt, author } = mockIssuable; @@ -72,6 +73,7 @@ describe('IssuableShowRoot', () => { confidential, createdAt, author, + taskCompletionStatus, }); expect(issuableHeader.find('.issuable-status-box').text()).toContain('Open'); expect(issuableHeader.find('.detail-page-header-actions button.js-close').exists()).toBe( @@ -111,6 +113,26 @@ describe('IssuableShowRoot', () => { expect(wrapper.emitted('edit-issuable')).toBeTruthy(); }); + it('component emits `task-list-update-success` event bubbled via issuable-body', () => { + const issuableBody = wrapper.find(IssuableBody); + const eventParam = { + foo: 'bar', + }; + + issuableBody.vm.$emit('task-list-update-success', eventParam); + + expect(wrapper.emitted('task-list-update-success')).toBeTruthy(); + expect(wrapper.emitted('task-list-update-success')[0]).toEqual([eventParam]); + }); + + it('component emits `task-list-update-failure` event bubbled via issuable-body', () => { + const issuableBody = wrapper.find(IssuableBody); + + issuableBody.vm.$emit('task-list-update-failure'); + + expect(wrapper.emitted('task-list-update-failure')).toBeTruthy(); + }); + it('component emits `sidebar-toggle` event bubbled via issuable-sidebar', () => { const issuableSidebar = wrapper.find(IssuableSidebar); diff --git a/spec/frontend/issuable_show/mock_data.js b/spec/frontend/issuable_show/mock_data.js index af854f420bc..9ecff705617 100644 --- a/spec/frontend/issuable_show/mock_data.js +++ b/spec/frontend/issuable_show/mock_data.js @@ -12,6 +12,7 @@ export const mockIssuable = { blocked: false, confidential: false, updatedBy: issuable.author, + type: 'ISSUE', currentUserTodos: { nodes: [ { @@ -26,11 +27,18 @@ export const mockIssuableShowProps = { issuable: mockIssuable, descriptionHelpPath: '/help/user/markdown', descriptionPreviewPath: '/gitlab-org/gitlab-shell/preview_markdown', + taskListUpdatePath: `${mockIssuable.webUrl}.json`, + taskListLockVersion: 1, editFormVisible: false, enableAutocomplete: true, enableAutosave: true, + enableTaskList: true, enableEdit: true, showFieldTitle: false, statusBadgeClass: 'status-box-open', statusIcon: 'issue-open-m', + taskCompletionStatus: { + completedCount: 0, + count: 5, + }, }; diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js index 9e1bc8242fe..b8860e93a22 100644 --- a/spec/frontend/issue_show/components/app_spec.js +++ b/spec/frontend/issue_show/components/app_spec.js @@ -166,40 +166,6 @@ describe('Issuable output', () => { }); }); - it('opens reCAPTCHA modal if update rejected as spam', () => { - let modal; - - jest.spyOn(wrapper.vm.service, 'updateIssuable').mockResolvedValue({ - data: { - recaptcha_html: '<div class="g-recaptcha">recaptcha_html</div>', - }, - }); - - wrapper.vm.canUpdate = true; - wrapper.vm.showForm = true; - - return wrapper.vm - .$nextTick() - .then(() => { - wrapper.vm.$refs.recaptchaModal.scriptSrc = '//scriptsrc'; - return wrapper.vm.updateIssuable(); - }) - .then(() => { - modal = wrapper.find('.js-recaptcha-modal'); - expect(modal.isVisible()).toBe(true); - expect(modal.find('.g-recaptcha').text()).toEqual('recaptcha_html'); - expect(document.body.querySelector('.js-recaptcha-script').src).toMatch('//scriptsrc'); - }) - .then(() => { - modal.find('.close').trigger('click'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(modal.isVisible()).toBe(false); - expect(document.body.querySelector('.js-recaptcha-script')).toBeNull(); - }); - }); - describe('Pinned links propagated', () => { it.each` prop | value @@ -422,7 +388,18 @@ describe('Issuable output', () => { formSpy = jest.spyOn(wrapper.vm, 'updateAndShowForm'); }); - it('shows the form if template names request is successful', () => { + it('shows the form if template names as hash request is successful', () => { + const mockData = { + test: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], + }; + mock.onGet('/issuable-templates-path').reply(() => Promise.resolve([200, mockData])); + + return wrapper.vm.requestTemplatesAndShowForm().then(() => { + expect(formSpy).toHaveBeenCalledWith(mockData); + }); + }); + + it('shows the form if template names as array request is successful', () => { const mockData = [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }]; mock.onGet('/issuable-templates-path').reply(() => Promise.resolve([200, mockData])); diff --git a/spec/frontend/issue_show/components/description_spec.js b/spec/frontend/issue_show/components/description_spec.js index d59a257a2be..70c04280675 100644 --- a/spec/frontend/issue_show/components/description_spec.js +++ b/spec/frontend/issue_show/components/description_spec.js @@ -70,36 +70,6 @@ describe('Description component', () => { }); }); - it('opens reCAPTCHA dialog if update rejected as spam', () => { - let modal; - const recaptchaChild = vm.$children.find( - // eslint-disable-next-line no-underscore-dangle - (child) => child.$options._componentTag === 'recaptcha-modal', - ); - - recaptchaChild.scriptSrc = '//scriptsrc'; - - vm.taskListUpdateSuccess({ - recaptcha_html: '<div class="g-recaptcha">recaptcha_html</div>', - }); - - return vm - .$nextTick() - .then(() => { - modal = vm.$el.querySelector('.js-recaptcha-modal'); - - expect(modal.style.display).not.toEqual('none'); - expect(modal.querySelector('.g-recaptcha').textContent).toEqual('recaptcha_html'); - expect(document.body.querySelector('.js-recaptcha-script').src).toMatch('//scriptsrc'); - }) - .then(() => modal.querySelector('.close').click()) - .then(() => vm.$nextTick()) - .then(() => { - expect(modal.style.display).toEqual('none'); - expect(document.body.querySelector('.js-recaptcha-script')).toBeNull(); - }); - }); - it('applies syntax highlighting and math when description changed', () => { const vmSpy = jest.spyOn(vm, 'renderGFM'); const prototypeSpy = jest.spyOn($.prototype, 'renderGFM'); @@ -144,7 +114,6 @@ describe('Description component', () => { dataType: 'issuableType', fieldName: 'description', selector: '.detail-page-description', - onSuccess: expect.any(Function), onError: expect.any(Function), lockVersion: 0, }); diff --git a/spec/frontend/issue_show/components/fields/description_template_spec.js b/spec/frontend/issue_show/components/fields/description_template_spec.js index 1193d4f8add..dc126c53f5e 100644 --- a/spec/frontend/issue_show/components/fields/description_template_spec.js +++ b/spec/frontend/issue_show/components/fields/description_template_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import descriptionTemplate from '~/issue_show/components/fields/description_template.vue'; -describe('Issue description template component', () => { +describe('Issue description template component with templates as hash', () => { let vm; let formState; @@ -14,7 +14,9 @@ describe('Issue description template component', () => { vm = new Component({ propsData: { formState, - issuableTemplates: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], + issuableTemplates: { + test: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], + }, projectId: 1, projectPath: '/', namespacePath: '/', @@ -23,9 +25,9 @@ describe('Issue description template component', () => { }).$mount(); }); - it('renders templates as JSON array in data attribute', () => { + it('renders templates as JSON hash in data attribute', () => { expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe( - '[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]', + '{"test":[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]}', ); }); @@ -41,3 +43,32 @@ describe('Issue description template component', () => { expect(vm.issuableTemplate.editor.getValue()).toBe('testing new template'); }); }); + +describe('Issue description template component with templates as array', () => { + let vm; + let formState; + + beforeEach(() => { + const Component = Vue.extend(descriptionTemplate); + formState = { + description: 'test', + }; + + vm = new Component({ + propsData: { + formState, + issuableTemplates: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }], + projectId: 1, + projectPath: '/', + namespacePath: '/', + projectNamespace: '/', + }, + }).$mount(); + }); + + it('renders templates as JSON array in data attribute', () => { + expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe( + '[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]', + ); + }); +}); diff --git a/spec/frontend/issue_show/components/form_spec.js b/spec/frontend/issue_show/components/form_spec.js index 4a8ec3cf66a..fc2e224ad92 100644 --- a/spec/frontend/issue_show/components/form_spec.js +++ b/spec/frontend/issue_show/components/form_spec.js @@ -42,7 +42,7 @@ describe('Inline edit form component', () => { expect(vm.$el.querySelector('.js-issuable-selector-wrap')).toBeNull(); }); - it('renders template selector when templates exists', () => { + it('renders template selector when templates as array exists', () => { createComponent({ issuableTemplates: [ { name: 'test', id: 'test', project_path: 'test', namespace_path: 'test' }, @@ -52,6 +52,16 @@ describe('Inline edit form component', () => { expect(vm.$el.querySelector('.js-issuable-selector-wrap')).not.toBeNull(); }); + it('renders template selector when templates as hash exists', () => { + createComponent({ + issuableTemplates: { + test: [{ name: 'test', id: 'test', project_path: 'test', namespace_path: 'test' }], + }, + }); + + expect(vm.$el.querySelector('.js-issuable-selector-wrap')).not.toBeNull(); + }); + it('hides locked warning by default', () => { createComponent(); diff --git a/spec/frontend/issue_spec.js b/spec/frontend/issue_spec.js index fb6caef41e2..952ef54d286 100644 --- a/spec/frontend/issue_spec.js +++ b/spec/frontend/issue_spec.js @@ -1,91 +1,90 @@ +import { getByText } from '@testing-library/dom'; import MockAdapter from 'axios-mock-adapter'; -import $ from 'jquery'; +import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import Issue from '~/issue'; import axios from '~/lib/utils/axios_utils'; -import '~/lib/utils/text_utility'; describe('Issue', () => { - let $boxClosed; - let $boxOpen; let testContext; + let mock; beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(/(.*)\/related_branches$/).reply(200, {}); + testContext = {}; + testContext.issue = new Issue(); }); - preloadFixtures('issues/closed-issue.html'); - preloadFixtures('issues/open-issue.html'); - - function expectVisibility($element, shouldBeVisible) { - if (shouldBeVisible) { - expect($element).not.toHaveClass('hidden'); - } else { - expect($element).toHaveClass('hidden'); - } - } - - function expectIssueState(isIssueOpen) { - expectVisibility($boxClosed, !isIssueOpen); - expectVisibility($boxOpen, isIssueOpen); - } - - function findElements() { - $boxClosed = $('div.status-box-issue-closed'); - - expect($boxClosed).toExist(); - expect($boxClosed).toHaveText('Closed'); + afterEach(() => { + mock.restore(); + testContext.issue.dispose(); + }); - $boxOpen = $('div.status-box-open'); + const getIssueCounter = () => document.querySelector('.issue_counter'); + const getOpenStatusBox = () => + getByText(document, (_, el) => el.textContent.match(/Open/), { + selector: '.status-box-open', + }); + const getClosedStatusBox = () => + getByText(document, (_, el) => el.textContent.match(/Closed/), { + selector: '.status-box-issue-closed', + }); - expect($boxOpen).toExist(); - expect($boxOpen).toHaveText('Open'); - } + describe.each` + desc | isIssueInitiallyOpen | expectedCounterText + ${'with an initially open issue'} | ${true} | ${'1,000'} + ${'with an initially closed issue'} | ${false} | ${'1,002'} + `('$desc', ({ isIssueInitiallyOpen, expectedCounterText }) => { + beforeEach(() => { + if (isIssueInitiallyOpen) { + loadFixtures('issues/open-issue.html'); + } else { + loadFixtures('issues/closed-issue.html'); + } - [true, false].forEach((isIssueInitiallyOpen) => { - describe(`with ${isIssueInitiallyOpen ? 'open' : 'closed'} issue`, () => { - const action = isIssueInitiallyOpen ? 'close' : 'reopen'; - let mock; + testContext.issueCounter = getIssueCounter(); + testContext.statusBoxClosed = getClosedStatusBox(); + testContext.statusBoxOpen = getOpenStatusBox(); - function setup() { - testContext.issue = new Issue(); - expectIssueState(isIssueInitiallyOpen); + testContext.issueCounter.textContent = '1,001'; + }); - testContext.$projectIssuesCounter = $('.issue_counter').first(); - testContext.$projectIssuesCounter.text('1,001'); + it(`has the proper visible status box when ${isIssueInitiallyOpen ? 'open' : 'closed'}`, () => { + if (isIssueInitiallyOpen) { + expect(testContext.statusBoxClosed).toHaveClass('hidden'); + expect(testContext.statusBoxOpen).not.toHaveClass('hidden'); + } else { + expect(testContext.statusBoxClosed).not.toHaveClass('hidden'); + expect(testContext.statusBoxOpen).toHaveClass('hidden'); } + }); + describe('when vue app triggers change', () => { beforeEach(() => { - if (isIssueInitiallyOpen) { - loadFixtures('issues/open-issue.html'); - } else { - loadFixtures('issues/closed-issue.html'); - } - - mock = new MockAdapter(axios); - mock.onGet(/(.*)\/related_branches$/).reply(200, {}); - jest.spyOn(axios, 'get'); - - findElements(isIssueInitiallyOpen); - }); - - afterEach(() => { - mock.restore(); - $('div.flash-alert').remove(); - }); - - it(`${action}s the issue on dispatch of issuable_vue_app:change event`, () => { - setup(); - document.dispatchEvent( - new CustomEvent('issuable_vue_app:change', { + new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, { detail: { data: { id: 1 }, isClosed: isIssueInitiallyOpen, }, }), ); + }); + + it('displays correct status box', () => { + if (isIssueInitiallyOpen) { + expect(testContext.statusBoxClosed).not.toHaveClass('hidden'); + expect(testContext.statusBoxOpen).toHaveClass('hidden'); + } else { + expect(testContext.statusBoxClosed).toHaveClass('hidden'); + expect(testContext.statusBoxOpen).not.toHaveClass('hidden'); + } + }); - expectIssueState(!isIssueInitiallyOpen); + it('updates issueCounter text', () => { + expect(testContext.issueCounter).toBeVisible(); + expect(testContext.issueCounter).toHaveText(expectedCounterText); }); }); }); diff --git a/spec/frontend/issues_list/components/issuable_spec.js b/spec/frontend/issues_list/components/issuable_spec.js index a8bf124373b..97d841c861d 100644 --- a/spec/frontend/issues_list/components/issuable_spec.js +++ b/spec/frontend/issues_list/components/issuable_spec.js @@ -1,4 +1,4 @@ -import { GlSprintf, GlLabel, GlIcon } from '@gitlab/ui'; +import { GlSprintf, GlLabel, GlIcon, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; import { trimText } from 'helpers/text_helper'; @@ -31,6 +31,7 @@ const TEST_MILESTONE = { }; const TEXT_CLOSED = 'CLOSED'; const TEST_META_COUNT = 100; +const MOCK_GITLAB_URL = 'http://0.0.0.0:3000'; describe('Issuable component', () => { let issuable; @@ -54,6 +55,7 @@ describe('Issuable component', () => { beforeEach(() => { issuable = { ...simpleIssue }; + gon.gitlab_url = MOCK_GITLAB_URL; }); afterEach(() => { @@ -190,15 +192,42 @@ describe('Issuable component', () => { expect(wrapper.classes('closed')).toBe(false); }); - it('renders fuzzy opened date and author', () => { + it('renders fuzzy created date and author', () => { expect(trimText(findOpenedAgoContainer().text())).toContain( - `opened 1 month ago by ${TEST_USER_NAME}`, + `created 1 month ago by ${TEST_USER_NAME}`, ); }); it('renders no comments', () => { expect(findNotes().classes('no-comments')).toBe(true); }); + + it.each` + gitlabWebUrl | webUrl | expectedHref | expectedTarget | isExternal + ${undefined} | ${`${MOCK_GITLAB_URL}/issue`} | ${`${MOCK_GITLAB_URL}/issue`} | ${undefined} | ${false} + ${undefined} | ${'https://jira.com/issue'} | ${'https://jira.com/issue'} | ${'_blank'} | ${true} + ${'/gitlab-org/issue'} | ${'https://jira.com/issue'} | ${'/gitlab-org/issue'} | ${undefined} | ${false} + `( + 'renders issuable title correctly when `gitlabWebUrl` is `$gitlabWebUrl` and webUrl is `$webUrl`', + async ({ webUrl, gitlabWebUrl, expectedHref, expectedTarget, isExternal }) => { + factory({ + issuable: { + ...issuable, + web_url: webUrl, + gitlab_web_url: gitlabWebUrl, + }, + }); + + const titleEl = findIssuableTitle(); + + expect(titleEl.exists()).toBe(true); + expect(titleEl.find(GlLink).attributes('href')).toBe(expectedHref); + expect(titleEl.find(GlLink).attributes('target')).toBe(expectedTarget); + expect(titleEl.find(GlLink).text()).toBe(issuable.title); + + expect(titleEl.find(GlIcon).exists()).toBe(isExternal); + }, + ); }); describe('with confidential issuable', () => { diff --git a/spec/frontend/issues_list/components/issue_card_time_info_spec.js b/spec/frontend/issues_list/components/issue_card_time_info_spec.js new file mode 100644 index 00000000000..614ad586ec9 --- /dev/null +++ b/spec/frontend/issues_list/components/issue_card_time_info_spec.js @@ -0,0 +1,109 @@ +import { GlIcon, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { useFakeDate } from 'helpers/fake_date'; +import IssueCardTimeInfo from '~/issues_list/components/issue_card_time_info.vue'; + +describe('IssuesListApp component', () => { + useFakeDate(2020, 11, 11); + + let wrapper; + + const issue = { + milestone: { + dueDate: '2020-12-17', + startDate: '2020-12-10', + title: 'My milestone', + webUrl: '/milestone/webUrl', + }, + dueDate: '2020-12-12', + timeStats: { + humanTimeEstimate: '1w', + }, + }; + + const findMilestone = () => wrapper.find('[data-testid="issuable-milestone"]'); + const findMilestoneTitle = () => findMilestone().find(GlLink).attributes('title'); + const findDueDate = () => wrapper.find('[data-testid="issuable-due-date"]'); + + const mountComponent = ({ + dueDate = issue.dueDate, + milestoneDueDate = issue.milestone.dueDate, + milestoneStartDate = issue.milestone.startDate, + } = {}) => + shallowMount(IssueCardTimeInfo, { + propsData: { + issue: { + ...issue, + milestone: { + ...issue.milestone, + dueDate: milestoneDueDate, + startDate: milestoneStartDate, + }, + dueDate, + }, + }, + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('milestone', () => { + it('renders', () => { + wrapper = mountComponent(); + + const milestone = findMilestone(); + + expect(milestone.text()).toBe(issue.milestone.title); + expect(milestone.find(GlIcon).props('name')).toBe('clock'); + expect(milestone.find(GlLink).attributes('href')).toBe(issue.milestone.webUrl); + }); + + describe.each` + time | text | milestoneDueDate | milestoneStartDate | expected + ${'due date is in past'} | ${'Past due'} | ${'2020-09-09'} | ${null} | ${'Sep 9, 2020 (Past due)'} + ${'due date is today'} | ${'Today'} | ${'2020-12-11'} | ${null} | ${'Dec 11, 2020 (Today)'} + ${'start date is in future'} | ${'Upcoming'} | ${'2021-03-01'} | ${'2021-02-01'} | ${'Mar 1, 2021 (Upcoming)'} + ${'due date is in future'} | ${'2 weeks remaining'} | ${'2020-12-25'} | ${null} | ${'Dec 25, 2020 (2 weeks remaining)'} + `('when $description', ({ text, milestoneDueDate, milestoneStartDate, expected }) => { + it(`renders with "${text}"`, () => { + wrapper = mountComponent({ milestoneDueDate, milestoneStartDate }); + + expect(findMilestoneTitle()).toBe(expected); + }); + }); + }); + + describe('due date', () => { + describe('when upcoming', () => { + it('renders', () => { + wrapper = mountComponent(); + + const dueDate = findDueDate(); + + expect(dueDate.text()).toBe('Dec 12, 2020'); + expect(dueDate.attributes('title')).toBe('Due date'); + expect(dueDate.find(GlIcon).props('name')).toBe('calendar'); + expect(dueDate.classes()).not.toContain('gl-text-red-500'); + }); + }); + + describe('when in the past', () => { + it('renders in red', () => { + wrapper = mountComponent({ dueDate: new Date('2020-10-10') }); + + expect(findDueDate().classes()).toContain('gl-text-red-500'); + }); + }); + }); + + it('renders time estimate', () => { + wrapper = mountComponent(); + + const timeEstimate = wrapper.find('[data-testid="time-estimate"]'); + + expect(timeEstimate.text()).toBe(issue.timeStats.humanTimeEstimate); + expect(timeEstimate.attributes('title')).toBe('Estimate'); + expect(timeEstimate.find(GlIcon).props('name')).toBe('timer'); + }); +}); diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js new file mode 100644 index 00000000000..1053e8934c9 --- /dev/null +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -0,0 +1,98 @@ +import { shallowMount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; +import IssuesListApp from '~/issues_list/components/issues_list_app.vue'; +import axios from '~/lib/utils/axios_utils'; + +describe('IssuesListApp component', () => { + let axiosMock; + let wrapper; + + const fullPath = 'path/to/project'; + const endpoint = 'api/endpoint'; + const state = 'opened'; + const xPage = 1; + const xTotal = 25; + const fetchIssuesResponse = { + data: [], + headers: { + 'x-page': xPage, + 'x-total': xTotal, + }, + }; + + const findIssuableList = () => wrapper.findComponent(IssuableList); + + const mountComponent = () => + shallowMount(IssuesListApp, { + provide: { + endpoint, + fullPath, + }, + }); + + beforeEach(async () => { + axiosMock = new AxiosMockAdapter(axios); + axiosMock.onGet(endpoint).reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers); + wrapper = mountComponent(); + await waitForPromises(); + }); + + afterEach(() => { + axiosMock.reset(); + wrapper.destroy(); + }); + + it('renders IssuableList', () => { + expect(findIssuableList().props()).toMatchObject({ + namespace: fullPath, + recentSearchesStorageKey: 'issues', + searchInputPlaceholder: 'Search or filter results…', + showPaginationControls: true, + issuables: [], + totalItems: xTotal, + currentPage: xPage, + previousPage: xPage - 1, + nextPage: xPage + 1, + urlParams: { page: xPage, state }, + }); + }); + + describe('when "page-change" event is emitted', () => { + const data = [{ id: 10, title: 'title', state }]; + const page = 2; + const totalItems = 21; + + beforeEach(async () => { + axiosMock.onGet(endpoint).reply(200, data, { + 'x-page': page, + 'x-total': totalItems, + }); + + findIssuableList().vm.$emit('page-change', page); + + await waitForPromises(); + }); + + it('fetches issues with expected params', async () => { + expect(axiosMock.history.get[1].params).toEqual({ + page, + per_page: 20, + state, + with_labels_details: true, + }); + }); + + it('updates IssuableList with response data', () => { + expect(findIssuableList().props()).toMatchObject({ + issuables: data, + totalItems, + currentPage: page, + previousPage: page - 1, + nextPage: page + 1, + urlParams: { page, state }, + }); + }); + }); +}); diff --git a/spec/frontend/issues_list/components/jira_issues_list_root_spec.js b/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js index eecb092a330..0c96b95a61f 100644 --- a/spec/frontend/issues_list/components/jira_issues_list_root_spec.js +++ b/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js @@ -1,9 +1,9 @@ import { GlAlert, GlLabel } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; -import JiraIssuesListRoot from '~/issues_list/components/jira_issues_list_root.vue'; +import JiraIssuesImportStatus from '~/issues_list/components/jira_issues_import_status_app.vue'; -describe('JiraIssuesListRoot', () => { +describe('JiraIssuesImportStatus', () => { const issuesPath = 'gitlab-org/gitlab-test/-/issues'; const label = { color: '#333', @@ -19,7 +19,7 @@ describe('JiraIssuesListRoot', () => { shouldShowFinishedAlert = false, shouldShowInProgressAlert = false, } = {}) => - shallowMount(JiraIssuesListRoot, { + shallowMount(JiraIssuesImportStatus, { propsData: { canEdit: true, isJiraConfigured: true, diff --git a/spec/frontend/jira_connect/components/app_spec.js b/spec/frontend/jira_connect/components/app_spec.js index d11b66b2089..e2a5cd1be9d 100644 --- a/spec/frontend/jira_connect/components/app_spec.js +++ b/spec/frontend/jira_connect/components/app_spec.js @@ -1,10 +1,12 @@ -import { GlAlert, GlButton, GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlButton, GlModal, GlLink } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import JiraConnectApp from '~/jira_connect/components/app.vue'; import createStore from '~/jira_connect/store'; -import { SET_ERROR_MESSAGE } from '~/jira_connect/store/mutation_types'; +import { SET_ALERT } from '~/jira_connect/store/mutation_types'; +import { persistAlert } from '~/jira_connect/utils'; +import { __ } from '~/locale'; jest.mock('~/jira_connect/api'); @@ -13,21 +15,19 @@ describe('JiraConnectApp', () => { let store; const findAlert = () => wrapper.findComponent(GlAlert); + const findAlertLink = () => findAlert().find(GlLink); const findGlButton = () => wrapper.findComponent(GlButton); const findGlModal = () => wrapper.findComponent(GlModal); const findHeader = () => wrapper.findByTestId('new-jira-connect-ui-heading'); const findHeaderText = () => findHeader().text(); - const createComponent = (options = {}) => { + const createComponent = ({ provide, mountFn = shallowMount } = {}) => { store = createStore(); wrapper = extendedWrapper( - shallowMount(JiraConnectApp, { + mountFn(JiraConnectApp, { store, - provide: { - glFeatures: { newJiraConnectUi: true }, - }, - ...options, + provide, }), ); }; @@ -49,7 +49,6 @@ describe('JiraConnectApp', () => { beforeEach(() => { createComponent({ provide: { - glFeatures: { newJiraConnectUi: true }, usersPath: '/users', }, }); @@ -72,37 +71,72 @@ describe('JiraConnectApp', () => { }); }); - describe('newJiraConnectUi is false', () => { - it('does not render new UI', () => { - createComponent({ - provide: { - glFeatures: { newJiraConnectUi: false }, - }, - }); + describe('alert', () => { + it.each` + message | variant | alertShouldRender + ${'Test error'} | ${'danger'} | ${true} + ${'Test notice'} | ${'info'} | ${true} + ${''} | ${undefined} | ${false} + ${undefined} | ${undefined} | ${false} + `( + 'renders correct alert when message is `$message` and variant is `$variant`', + async ({ message, alertShouldRender, variant }) => { + createComponent(); + + store.commit(SET_ALERT, { message, variant }); + await wrapper.vm.$nextTick(); + + const alert = findAlert(); + + expect(alert.exists()).toBe(alertShouldRender); + if (alertShouldRender) { + expect(alert.isVisible()).toBe(alertShouldRender); + expect(alert.html()).toContain(message); + expect(alert.props('variant')).toBe(variant); + expect(findAlertLink().exists()).toBe(false); + } + }, + ); + + it('hides alert on @dismiss event', async () => { + createComponent(); + + store.commit(SET_ALERT, { message: 'test message' }); + await wrapper.vm.$nextTick(); + + findAlert().vm.$emit('dismiss'); + await wrapper.vm.$nextTick(); - expect(findHeader().exists()).toBe(false); + expect(findAlert().exists()).toBe(false); }); - }); - it.each` - errorMessage | errorShouldRender - ${'Test error'} | ${true} - ${''} | ${false} - ${undefined} | ${false} - `( - 'renders correct alert when errorMessage is `$errorMessage`', - async ({ errorMessage, errorShouldRender }) => { - createComponent(); + it('renders link when `linkUrl` is set', async () => { + createComponent({ mountFn: mount }); - store.commit(SET_ERROR_MESSAGE, errorMessage); + store.commit(SET_ALERT, { + message: __('test message %{linkStart}test link%{linkEnd}'), + linkUrl: 'https://gitlab.com', + }); await wrapper.vm.$nextTick(); - expect(findAlert().exists()).toBe(errorShouldRender); - if (errorShouldRender) { - expect(findAlert().isVisible()).toBe(errorShouldRender); - expect(findAlert().html()).toContain(errorMessage); - } - }, - ); + const alertLink = findAlertLink(); + + expect(alertLink.exists()).toBe(true); + expect(alertLink.text()).toContain('test link'); + expect(alertLink.attributes('href')).toBe('https://gitlab.com'); + }); + + describe('when alert is set in localStoage', () => { + it('renders alert on mount', () => { + persistAlert({ message: 'error message' }); + createComponent(); + + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + expect(alert.html()).toContain('error message'); + }); + }); + }); }); }); diff --git a/spec/frontend/jira_connect/components/groups_list_item_spec.js b/spec/frontend/jira_connect/components/groups_list_item_spec.js index bb247534aca..da16223255c 100644 --- a/spec/frontend/jira_connect/components/groups_list_item_spec.js +++ b/spec/frontend/jira_connect/components/groups_list_item_spec.js @@ -5,8 +5,11 @@ import waitForPromises from 'helpers/wait_for_promises'; import * as JiraConnectApi from '~/jira_connect/api'; import GroupsListItem from '~/jira_connect/components/groups_list_item.vue'; +import { persistAlert } from '~/jira_connect/utils'; import { mockGroup1 } from '../mock_data'; +jest.mock('~/jira_connect/utils'); + describe('GroupsListItem', () => { let wrapper; const mockSubscriptionPath = 'subscriptionPath'; @@ -85,7 +88,16 @@ describe('GroupsListItem', () => { expect(findLinkButton().props('loading')).toBe(true); + await waitForPromises(); + expect(addSubscriptionSpy).toHaveBeenCalledWith(mockSubscriptionPath, mockGroup1.full_path); + expect(persistAlert).toHaveBeenCalledWith({ + linkUrl: '/help/integration/jira_development_panel.html#usage', + message: + 'You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}', + title: 'Namespace successfully linked', + variant: 'success', + }); }); describe('when request is successful', () => { diff --git a/spec/frontend/jira_connect/store/mutations_spec.js b/spec/frontend/jira_connect/store/mutations_spec.js index d1f9d22b3de..584b17b36f7 100644 --- a/spec/frontend/jira_connect/store/mutations_spec.js +++ b/spec/frontend/jira_connect/store/mutations_spec.js @@ -8,11 +8,21 @@ describe('JiraConnect store mutations', () => { localState = state(); }); - describe('SET_ERROR_MESSAGE', () => { - it('sets error message', () => { - mutations.SET_ERROR_MESSAGE(localState, 'test error'); + describe('SET_ALERT', () => { + it('sets alert state', () => { + mutations.SET_ALERT(localState, { + message: 'test error', + variant: 'danger', + title: 'test title', + linkUrl: 'linkUrl', + }); - expect(localState.errorMessage).toBe('test error'); + expect(localState.alert).toMatchObject({ + message: 'test error', + variant: 'danger', + title: 'test title', + linkUrl: 'linkUrl', + }); }); }); }); diff --git a/spec/frontend/jira_connect/utils_spec.js b/spec/frontend/jira_connect/utils_spec.js new file mode 100644 index 00000000000..5310bce384b --- /dev/null +++ b/spec/frontend/jira_connect/utils_spec.js @@ -0,0 +1,32 @@ +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { ALERT_LOCALSTORAGE_KEY } from '~/jira_connect/constants'; +import { persistAlert, retrieveAlert } from '~/jira_connect/utils'; + +useLocalStorageSpy(); + +describe('JiraConnect utils', () => { + describe('alert utils', () => { + it.each` + arg | expectedRetrievedValue + ${{ title: 'error' }} | ${{ title: 'error' }} + ${{ title: 'error', randomKey: 'test' }} | ${{ title: 'error' }} + ${{ title: 'error', message: 'error message', linkUrl: 'link', variant: 'danger' }} | ${{ title: 'error', message: 'error message', linkUrl: 'link', variant: 'danger' }} + ${undefined} | ${{}} + `( + 'persists and retrieves alert data from localStorage when arg is $arg', + ({ arg, expectedRetrievedValue }) => { + persistAlert(arg); + + expect(localStorage.setItem).toHaveBeenCalledWith( + ALERT_LOCALSTORAGE_KEY, + JSON.stringify(expectedRetrievedValue), + ); + + const retrievedValue = retrieveAlert(); + + expect(localStorage.getItem).toHaveBeenCalledWith(ALERT_LOCALSTORAGE_KEY); + expect(retrievedValue).toEqual(expectedRetrievedValue); + }, + ); + }); +}); diff --git a/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js index 8fc5b071e54..6914b8d4fa1 100644 --- a/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js +++ b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js @@ -54,7 +54,7 @@ describe('Job Sidebar Retry Button', () => { expect(findRetryButton().attributes()).toMatchObject({ category: 'primary', - variant: 'info', + variant: 'confirm', }); }); }); diff --git a/spec/frontend/jobs/components/jobs_container_spec.js b/spec/frontend/jobs/components/jobs_container_spec.js index 9a336489101..1cde72682a2 100644 --- a/spec/frontend/jobs/components/jobs_container_spec.js +++ b/spec/frontend/jobs/components/jobs_container_spec.js @@ -1,10 +1,10 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import component from '~/jobs/components/jobs_container.vue'; +import { GlLink } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import JobsContainer from '~/jobs/components/jobs_container.vue'; describe('Jobs List block', () => { - const Component = Vue.extend(component); - let vm; + let wrapper; const retried = { status: { @@ -52,80 +52,96 @@ describe('Jobs List block', () => { tooltip: 'build - passed', }; + const findAllJobs = () => wrapper.findAllComponents(GlLink); + const findJob = () => findAllJobs().at(0); + + const findArrowIcon = () => wrapper.findByTestId('arrow-right-icon'); + const findRetryIcon = () => wrapper.findByTestId('retry-icon'); + + const createComponent = (props) => { + wrapper = extendedWrapper( + mount(JobsContainer, { + propsData: { + ...props, + }, + }), + ); + }; + afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - it('renders list of jobs', () => { - vm = mountComponent(Component, { + it('renders a list of jobs', () => { + createComponent({ jobs: [job, retried, active], jobId: 12313, }); - expect(vm.$el.querySelectorAll('a').length).toEqual(3); + expect(findAllJobs()).toHaveLength(3); }); - it('renders arrow right when job id matches `jobId`', () => { - vm = mountComponent(Component, { + it('renders the arrow right icon when job id matches `jobId`', () => { + createComponent({ jobs: [active], jobId: active.id, }); - expect(vm.$el.querySelector('a .js-arrow-right')).not.toBeNull(); + expect(findArrowIcon().exists()).toBe(true); }); - it('does not render arrow right when job is not active', () => { - vm = mountComponent(Component, { + it('does not render the arrow right icon when the job is not active', () => { + createComponent({ jobs: [job], jobId: active.id, }); - expect(vm.$el.querySelector('a .js-arrow-right')).toBeNull(); + expect(findArrowIcon().exists()).toBe(false); }); - it('renders job name when present', () => { - vm = mountComponent(Component, { + it('renders the job name when present', () => { + createComponent({ jobs: [job], jobId: active.id, }); - expect(vm.$el.querySelector('a').textContent.trim()).toContain(job.name); - expect(vm.$el.querySelector('a').textContent.trim()).not.toContain(job.id); + expect(findJob().text()).toBe(job.name); + expect(findJob().text()).not.toContain(job.id); }); it('renders job id when job name is not available', () => { - vm = mountComponent(Component, { + createComponent({ jobs: [retried], jobId: active.id, }); - expect(vm.$el.querySelector('a').textContent.trim()).toContain(retried.id); + expect(findJob().text()).toBe(retried.id.toString()); }); it('links to the job page', () => { - vm = mountComponent(Component, { + createComponent({ jobs: [job], jobId: active.id, }); - expect(vm.$el.querySelector('a').getAttribute('href')).toEqual(job.status.details_path); + expect(findJob().attributes('href')).toBe(job.status.details_path); }); it('renders retry icon when job was retried', () => { - vm = mountComponent(Component, { + createComponent({ jobs: [retried], jobId: active.id, }); - expect(vm.$el.querySelector('.js-retry-icon')).not.toBeNull(); + expect(findRetryIcon().exists()).toBe(true); }); it('does not render retry icon when job was not retried', () => { - vm = mountComponent(Component, { + createComponent({ jobs: [job], jobId: active.id, }); - expect(vm.$el.querySelector('.js-retry-icon')).toBeNull(); + expect(findRetryIcon().exists()).toBe(false); }); }); diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index 32a24227cbd..2df0cb00f9a 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -764,6 +764,21 @@ describe('date addition/subtraction methods', () => { ); }); + describe('nYearsAfter', () => { + it.each` + date | numberOfYears | expected + ${'2020-07-06'} | ${1} | ${'2021-07-06'} + ${'2020-07-06'} | ${15} | ${'2035-07-06'} + `( + 'returns $expected for "$numberOfYears year(s) after $date"', + ({ date, numberOfYears, expected }) => { + expect(datetimeUtility.nYearsAfter(new Date(date), numberOfYears)).toEqual( + new Date(expected), + ); + }, + ); + }); + describe('nMonthsBefore', () => { // The previous month (February) has 28 days const march2019 = '2019-03-15T00:00:00.000Z'; @@ -1018,6 +1033,81 @@ describe('isToday', () => { }); }); +describe('isInPast', () => { + it.each` + date | expected + ${new Date('2024-12-15')} | ${false} + ${new Date('2020-07-06T00:00')} | ${false} + ${new Date('2020-07-05T23:59:59.999')} | ${true} + ${new Date('2020-07-05')} | ${true} + ${new Date('1999-03-21')} | ${true} + `('returns $expected for $date', ({ date, expected }) => { + expect(datetimeUtility.isInPast(date)).toBe(expected); + }); +}); + +describe('isInFuture', () => { + it.each` + date | expected + ${new Date('2024-12-15')} | ${true} + ${new Date('2020-07-07T00:00')} | ${true} + ${new Date('2020-07-06T23:59:59.999')} | ${false} + ${new Date('2020-07-06')} | ${false} + ${new Date('1999-03-21')} | ${false} + `('returns $expected for $date', ({ date, expected }) => { + expect(datetimeUtility.isInFuture(date)).toBe(expected); + }); +}); + +describe('fallsBefore', () => { + it.each` + dateA | dateB | expected + ${new Date('2020-07-06T23:59:59.999')} | ${new Date('2020-07-07T00:00')} | ${true} + ${new Date('2020-07-07T00:00')} | ${new Date('2020-07-06T23:59:59.999')} | ${false} + ${new Date('2020-04-04')} | ${new Date('2021-10-10')} | ${true} + ${new Date('2021-10-10')} | ${new Date('2020-04-04')} | ${false} + `('returns $expected for "$dateA falls before $dateB"', ({ dateA, dateB, expected }) => { + expect(datetimeUtility.fallsBefore(dateA, dateB)).toBe(expected); + }); +}); + +describe('removeTime', () => { + it.each` + date | expected + ${new Date('2020-07-07')} | ${new Date('2020-07-07T00:00:00.000')} + ${new Date('2020-07-07T00:00:00.001')} | ${new Date('2020-07-07T00:00:00.000')} + ${new Date('2020-07-07T23:59:59.999')} | ${new Date('2020-07-07T00:00:00.000')} + ${new Date('2020-07-07T12:34:56.789')} | ${new Date('2020-07-07T00:00:00.000')} + `('returns $expected for $date', ({ date, expected }) => { + expect(datetimeUtility.removeTime(date)).toEqual(expected); + }); +}); + +describe('getTimeRemainingInWords', () => { + it.each` + date | expected + ${new Date('2020-07-06T12:34:56.789')} | ${'0 days remaining'} + ${new Date('2020-07-07T12:34:56.789')} | ${'1 day remaining'} + ${new Date('2020-07-08T12:34:56.789')} | ${'2 days remaining'} + ${new Date('2020-07-12T12:34:56.789')} | ${'6 days remaining'} + ${new Date('2020-07-13T12:34:56.789')} | ${'1 week remaining'} + ${new Date('2020-07-19T12:34:56.789')} | ${'1 week remaining'} + ${new Date('2020-07-20T12:34:56.789')} | ${'2 weeks remaining'} + ${new Date('2020-07-27T12:34:56.789')} | ${'3 weeks remaining'} + ${new Date('2020-08-03T12:34:56.789')} | ${'4 weeks remaining'} + ${new Date('2020-08-05T12:34:56.789')} | ${'4 weeks remaining'} + ${new Date('2020-08-06T12:34:56.789')} | ${'1 month remaining'} + ${new Date('2020-09-06T12:34:56.789')} | ${'2 months remaining'} + ${new Date('2021-06-06T12:34:56.789')} | ${'11 months remaining'} + ${new Date('2021-07-06T12:34:56.789')} | ${'1 year remaining'} + ${new Date('2022-07-06T12:34:56.789')} | ${'2 years remaining'} + ${new Date('2030-07-06T12:34:56.789')} | ${'10 years remaining'} + ${new Date('2119-07-06T12:34:56.789')} | ${'99 years remaining'} + `('returns $expected for $date', ({ date, expected }) => { + expect(datetimeUtility.getTimeRemainingInWords(date)).toEqual(expected); + }); +}); + describe('getStartOfDay', () => { beforeEach(() => { timezoneMock.register('US/Eastern'); @@ -1046,3 +1136,32 @@ describe('getStartOfDay', () => { }, ); }); + +describe('getStartOfWeek', () => { + beforeEach(() => { + timezoneMock.register('US/Eastern'); + }); + + afterEach(() => { + timezoneMock.unregister(); + }); + + it.each` + inputAsString | options | expectedAsString + ${'2021-01-29T18:08:23.014Z'} | ${undefined} | ${'2021-01-25T05:00:00.000Z'} + ${'2021-01-29T13:08:23.014-05:00'} | ${undefined} | ${'2021-01-25T05:00:00.000Z'} + ${'2021-01-30T03:08:23.014+09:00'} | ${undefined} | ${'2021-01-25T05:00:00.000Z'} + ${'2021-01-28T18:08:23.014-10:00'} | ${undefined} | ${'2021-01-25T05:00:00.000Z'} + ${'2021-01-28T18:08:23.014-10:00'} | ${{}} | ${'2021-01-25T05:00:00.000Z'} + ${'2021-01-28T18:08:23.014-10:00'} | ${{ utc: false }} | ${'2021-01-25T05:00:00.000Z'} + ${'2021-01-28T18:08:23.014-10:00'} | ${{ utc: true }} | ${'2021-01-26T00:00:00.000Z'} + `( + 'when the provided date is $inputAsString and the options parameter is $options, returns $expectedAsString', + ({ inputAsString, options, expectedAsString }) => { + const inputDate = new Date(inputAsString); + const actual = datetimeUtility.getStartOfWeek(inputDate, options); + + expect(actual.toISOString()).toEqual(expectedAsString); + }, + ); +}); diff --git a/spec/frontend/lib/utils/experimentation_spec.js b/spec/frontend/lib/utils/experimentation_spec.js deleted file mode 100644 index 2c5d2f89297..00000000000 --- a/spec/frontend/lib/utils/experimentation_spec.js +++ /dev/null @@ -1,20 +0,0 @@ -import * as experimentUtils from '~/lib/utils/experimentation'; - -const TEST_KEY = 'abc'; - -describe('experiment Utilities', () => { - describe('isExperimentEnabled', () => { - it.each` - experiments | value - ${{ [TEST_KEY]: true }} | ${true} - ${{ [TEST_KEY]: false }} | ${false} - ${{ def: true }} | ${false} - ${{}} | ${false} - ${null} | ${false} - `('returns correct value of $value for experiments=$experiments', ({ experiments, value }) => { - window.gon = { experiments }; - - expect(experimentUtils.isExperimentEnabled(TEST_KEY)).toEqual(value); - }); - }); -}); diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js index 2f8f1092612..4dcd9211697 100644 --- a/spec/frontend/lib/utils/number_utility_spec.js +++ b/spec/frontend/lib/utils/number_utility_spec.js @@ -9,6 +9,7 @@ import { median, changeInPercent, formattedChangeInPercent, + isNumeric, } from '~/lib/utils/number_utils'; describe('Number Utils', () => { @@ -162,4 +163,25 @@ describe('Number Utils', () => { expect(formattedChangeInPercent(0, 1, { nonFiniteResult: '*' })).toBe('*'); }); }); + + describe('isNumeric', () => { + it.each` + value | outcome + ${0} | ${true} + ${12345} | ${true} + ${'0'} | ${true} + ${'12345'} | ${true} + ${1.0} | ${true} + ${'1.0'} | ${true} + ${'abcd'} | ${false} + ${'abcd100'} | ${false} + ${''} | ${false} + ${false} | ${false} + ${true} | ${false} + ${undefined} | ${false} + ${null} | ${false} + `('when called with $value it returns $outcome', ({ value, outcome }) => { + expect(isNumeric(value)).toBe(outcome); + }); + }); }); diff --git a/spec/frontend/lib/utils/select2_utils_spec.js b/spec/frontend/lib/utils/select2_utils_spec.js new file mode 100644 index 00000000000..6d601dd5ad1 --- /dev/null +++ b/spec/frontend/lib/utils/select2_utils_spec.js @@ -0,0 +1,100 @@ +import MockAdapter from 'axios-mock-adapter'; +import $ from 'jquery'; +import { setHTMLFixture } from 'helpers/fixtures'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import { select2AxiosTransport } from '~/lib/utils/select2_utils'; + +import 'select2/select2'; + +const TEST_URL = '/test/api/url'; +const TEST_SEARCH_DATA = { extraSearch: 'test' }; +const TEST_DATA = [{ id: 1 }]; +const TEST_SEARCH = 'FOO'; + +describe('lib/utils/select2_utils', () => { + let mock; + let resultsSpy; + + beforeEach(() => { + setHTMLFixture('<div><input id="root" /></div>'); + + mock = new MockAdapter(axios); + + resultsSpy = jest.fn().mockReturnValue({ results: [] }); + }); + + afterEach(() => { + mock.restore(); + }); + + const setupSelect2 = (input) => { + input.select2({ + ajax: { + url: TEST_URL, + quietMillis: 250, + transport: select2AxiosTransport, + data(search, page) { + return { + search, + page, + ...TEST_SEARCH_DATA, + }; + }, + results: resultsSpy, + }, + }); + }; + + const setupSelect2AndSearch = async () => { + const $input = $('#root'); + + setupSelect2($input); + + $input.select2('search', TEST_SEARCH); + + jest.runOnlyPendingTimers(); + await waitForPromises(); + }; + + describe('select2AxiosTransport', () => { + it('uses axios to make request', async () => { + // setup mock response + const replySpy = jest.fn(); + mock.onGet(TEST_URL).reply((...args) => replySpy(...args)); + + await setupSelect2AndSearch(); + + expect(replySpy).toHaveBeenCalledWith( + expect.objectContaining({ + url: TEST_URL, + method: 'get', + params: { + page: 1, + search: TEST_SEARCH, + ...TEST_SEARCH_DATA, + }, + }), + ); + }); + + it.each` + headers | pagination + ${{}} | ${{ more: false }} + ${{ 'X-PAGE': '1', 'x-next-page': 2 }} | ${{ more: true }} + `( + 'passes results and pagination to results callback, with headers=$headers', + async ({ headers, pagination }) => { + mock.onGet(TEST_URL).reply(200, TEST_DATA, headers); + + await setupSelect2AndSearch(); + + expect(resultsSpy).toHaveBeenCalledWith( + { results: TEST_DATA, pagination }, + 1, + expect.anything(), + ); + }, + ); + }); +}); diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js index 43de195c702..b538257fac0 100644 --- a/spec/frontend/lib/utils/text_markdown_spec.js +++ b/spec/frontend/lib/utils/text_markdown_spec.js @@ -171,27 +171,40 @@ describe('init markdown', () => { expect(textArea.value).toEqual(text.replace(selected, `[${selected}](url)`)); }); - it.each` - key | expected - ${'['} | ${`[${selected}]`} - ${'*'} | ${`**${selected}**`} - ${"'"} | ${`'${selected}'`} - ${'_'} | ${`_${selected}_`} - ${'`'} | ${`\`${selected}\``} - ${'"'} | ${`"${selected}"`} - ${'{'} | ${`{${selected}}`} - ${'('} | ${`(${selected})`} - ${'<'} | ${`<${selected}>`} - `('generates $expected when $key is pressed', ({ key, expected }) => { - const event = new KeyboardEvent('keydown', { key }); - - textArea.addEventListener('keydown', keypressNoteText); - textArea.dispatchEvent(event); - - expect(textArea.value).toEqual(text.replace(selected, expected)); + describe('surrounds selected text with matching character', () => { + it.each` + key | expected + ${'['} | ${`[${selected}]`} + ${'*'} | ${`**${selected}**`} + ${"'"} | ${`'${selected}'`} + ${'_'} | ${`_${selected}_`} + ${'`'} | ${`\`${selected}\``} + ${'"'} | ${`"${selected}"`} + ${'{'} | ${`{${selected}}`} + ${'('} | ${`(${selected})`} + ${'<'} | ${`<${selected}>`} + `('generates $expected when $key is pressed', ({ key, expected }) => { + const event = new KeyboardEvent('keydown', { key }); + gon.markdown_surround_selection = true; + + textArea.addEventListener('keydown', keypressNoteText); + textArea.dispatchEvent(event); + + expect(textArea.value).toEqual(text.replace(selected, expected)); + + // cursor placement should be after selection + 2 tag lengths + expect(textArea.selectionStart).toBe(selectedIndex + expected.length); + }); - // cursor placement should be after selection + 2 tag lengths - expect(textArea.selectionStart).toBe(selectedIndex + expected.length); + it('does nothing if user preference disabled', () => { + const event = new KeyboardEvent('keydown', { key: '[' }); + gon.markdown_surround_selection = false; + + textArea.addEventListener('keydown', keypressNoteText); + textArea.dispatchEvent(event); + + expect(textArea.value).toEqual(text); + }); }); describe('and text to be selected', () => { diff --git a/spec/frontend/lib/utils/unit_format/index_spec.js b/spec/frontend/lib/utils/unit_format/index_spec.js index 5b2fdf1f02b..7fd273f1b58 100644 --- a/spec/frontend/lib/utils/unit_format/index_spec.js +++ b/spec/frontend/lib/utils/unit_format/index_spec.js @@ -1,157 +1,213 @@ -import { getFormatter, SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; +import { + number, + percent, + percentHundred, + seconds, + milliseconds, + decimalBytes, + kilobytes, + megabytes, + gigabytes, + terabytes, + petabytes, + bytes, + kibibytes, + mebibytes, + gibibytes, + tebibytes, + pebibytes, + engineering, + getFormatter, + SUPPORTED_FORMATS, +} from '~/lib/utils/unit_format'; describe('unit_format', () => { - describe('when a supported format is provided, the returned function formats', () => { - it('numbers, by default', () => { - expect(getFormatter()(1)).toBe('1'); - }); - - it('numbers', () => { - const formatNumber = getFormatter(SUPPORTED_FORMATS.number); - - expect(formatNumber(1)).toBe('1'); - expect(formatNumber(100)).toBe('100'); - expect(formatNumber(1000)).toBe('1,000'); - expect(formatNumber(10000)).toBe('10,000'); - expect(formatNumber(1000000)).toBe('1,000,000'); - }); - - it('percent', () => { - const formatPercent = getFormatter(SUPPORTED_FORMATS.percent); + it('engineering', () => { + expect(engineering(1)).toBe('1'); + expect(engineering(100)).toBe('100'); + expect(engineering(1000)).toBe('1k'); + expect(engineering(10_000)).toBe('10k'); + expect(engineering(1_000_000)).toBe('1M'); + + expect(engineering(10 ** 9)).toBe('1G'); + }); - expect(formatPercent(1)).toBe('100%'); - expect(formatPercent(1, 2)).toBe('100.00%'); + it('number', () => { + expect(number(1)).toBe('1'); + expect(number(100)).toBe('100'); + expect(number(1000)).toBe('1,000'); + expect(number(10_000)).toBe('10,000'); + expect(number(1_000_000)).toBe('1,000,000'); - expect(formatPercent(0.1)).toBe('10%'); - expect(formatPercent(0.5)).toBe('50%'); + expect(number(10 ** 9)).toBe('1,000,000,000'); + }); - expect(formatPercent(0.888888)).toBe('89%'); - expect(formatPercent(0.888888, 2)).toBe('88.89%'); - expect(formatPercent(0.888888, 5)).toBe('88.88880%'); + it('percent', () => { + expect(percent(1)).toBe('100%'); + expect(percent(1, 2)).toBe('100.00%'); - expect(formatPercent(2)).toBe('200%'); - expect(formatPercent(10)).toBe('1,000%'); - }); + expect(percent(0.1)).toBe('10%'); + expect(percent(0.5)).toBe('50%'); - it('percentunit', () => { - const formatPercentHundred = getFormatter(SUPPORTED_FORMATS.percentHundred); + expect(percent(0.888888)).toBe('89%'); + expect(percent(0.888888, 2)).toBe('88.89%'); + expect(percent(0.888888, 5)).toBe('88.88880%'); - expect(formatPercentHundred(1)).toBe('1%'); - expect(formatPercentHundred(1, 2)).toBe('1.00%'); - - expect(formatPercentHundred(88.8888)).toBe('89%'); - expect(formatPercentHundred(88.8888, 2)).toBe('88.89%'); - expect(formatPercentHundred(88.8888, 5)).toBe('88.88880%'); + expect(percent(2)).toBe('200%'); + expect(percent(10)).toBe('1,000%'); + }); - expect(formatPercentHundred(100)).toBe('100%'); - expect(formatPercentHundred(100, 2)).toBe('100.00%'); + it('percentHundred', () => { + expect(percentHundred(1)).toBe('1%'); + expect(percentHundred(1, 2)).toBe('1.00%'); - expect(formatPercentHundred(200)).toBe('200%'); - expect(formatPercentHundred(1000)).toBe('1,000%'); - }); + expect(percentHundred(88.8888)).toBe('89%'); + expect(percentHundred(88.8888, 2)).toBe('88.89%'); + expect(percentHundred(88.8888, 5)).toBe('88.88880%'); - it('seconds', () => { - expect(getFormatter(SUPPORTED_FORMATS.seconds)(1)).toBe('1s'); - }); + expect(percentHundred(100)).toBe('100%'); + expect(percentHundred(100, 2)).toBe('100.00%'); - it('milliseconds', () => { - const formatMilliseconds = getFormatter(SUPPORTED_FORMATS.milliseconds); + expect(percentHundred(200)).toBe('200%'); + expect(percentHundred(1000)).toBe('1,000%'); + }); - expect(formatMilliseconds(1)).toBe('1ms'); - expect(formatMilliseconds(100)).toBe('100ms'); - expect(formatMilliseconds(1000)).toBe('1,000ms'); - expect(formatMilliseconds(10000)).toBe('10,000ms'); - expect(formatMilliseconds(1000000)).toBe('1,000,000ms'); - }); + it('seconds', () => { + expect(seconds(1)).toBe('1s'); + }); - it('decimalBytes', () => { - const formatDecimalBytes = getFormatter(SUPPORTED_FORMATS.decimalBytes); - - expect(formatDecimalBytes(1)).toBe('1B'); - expect(formatDecimalBytes(1, 1)).toBe('1.0B'); - - expect(formatDecimalBytes(10)).toBe('10B'); - expect(formatDecimalBytes(10 ** 2)).toBe('100B'); - expect(formatDecimalBytes(10 ** 3)).toBe('1kB'); - expect(formatDecimalBytes(10 ** 4)).toBe('10kB'); - expect(formatDecimalBytes(10 ** 5)).toBe('100kB'); - expect(formatDecimalBytes(10 ** 6)).toBe('1MB'); - expect(formatDecimalBytes(10 ** 7)).toBe('10MB'); - expect(formatDecimalBytes(10 ** 8)).toBe('100MB'); - expect(formatDecimalBytes(10 ** 9)).toBe('1GB'); - expect(formatDecimalBytes(10 ** 10)).toBe('10GB'); - expect(formatDecimalBytes(10 ** 11)).toBe('100GB'); - }); + it('milliseconds', () => { + expect(milliseconds(1)).toBe('1ms'); + expect(milliseconds(100)).toBe('100ms'); + expect(milliseconds(1000)).toBe('1,000ms'); + expect(milliseconds(10_000)).toBe('10,000ms'); + expect(milliseconds(1_000_000)).toBe('1,000,000ms'); + }); - it('kilobytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.kilobytes)(1)).toBe('1kB'); - expect(getFormatter(SUPPORTED_FORMATS.kilobytes)(1, 1)).toBe('1.0kB'); - }); + it('decimalBytes', () => { + expect(decimalBytes(1)).toBe('1B'); + expect(decimalBytes(1, 1)).toBe('1.0B'); + + expect(decimalBytes(10)).toBe('10B'); + expect(decimalBytes(10 ** 2)).toBe('100B'); + expect(decimalBytes(10 ** 3)).toBe('1kB'); + expect(decimalBytes(10 ** 4)).toBe('10kB'); + expect(decimalBytes(10 ** 5)).toBe('100kB'); + expect(decimalBytes(10 ** 6)).toBe('1MB'); + expect(decimalBytes(10 ** 7)).toBe('10MB'); + expect(decimalBytes(10 ** 8)).toBe('100MB'); + expect(decimalBytes(10 ** 9)).toBe('1GB'); + expect(decimalBytes(10 ** 10)).toBe('10GB'); + expect(decimalBytes(10 ** 11)).toBe('100GB'); + }); - it('megabytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.megabytes)(1)).toBe('1MB'); - expect(getFormatter(SUPPORTED_FORMATS.megabytes)(1, 1)).toBe('1.0MB'); - }); + it('kilobytes', () => { + expect(kilobytes(1)).toBe('1kB'); + expect(kilobytes(1, 1)).toBe('1.0kB'); + }); - it('gigabytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.gigabytes)(1)).toBe('1GB'); - expect(getFormatter(SUPPORTED_FORMATS.gigabytes)(1, 1)).toBe('1.0GB'); - }); + it('megabytes', () => { + expect(megabytes(1)).toBe('1MB'); + expect(megabytes(1, 1)).toBe('1.0MB'); + }); - it('terabytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.terabytes)(1)).toBe('1TB'); - expect(getFormatter(SUPPORTED_FORMATS.terabytes)(1, 1)).toBe('1.0TB'); - }); + it('gigabytes', () => { + expect(gigabytes(1)).toBe('1GB'); + expect(gigabytes(1, 1)).toBe('1.0GB'); + }); - it('petabytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.petabytes)(1)).toBe('1PB'); - expect(getFormatter(SUPPORTED_FORMATS.petabytes)(1, 1)).toBe('1.0PB'); - }); + it('terabytes', () => { + expect(terabytes(1)).toBe('1TB'); + expect(terabytes(1, 1)).toBe('1.0TB'); + }); - it('bytes', () => { - const formatBytes = getFormatter(SUPPORTED_FORMATS.bytes); + it('petabytes', () => { + expect(petabytes(1)).toBe('1PB'); + expect(petabytes(1, 1)).toBe('1.0PB'); + }); - expect(formatBytes(1)).toBe('1B'); - expect(formatBytes(1, 1)).toBe('1.0B'); + it('bytes', () => { + expect(bytes(1)).toBe('1B'); + expect(bytes(1, 1)).toBe('1.0B'); - expect(formatBytes(10)).toBe('10B'); - expect(formatBytes(100)).toBe('100B'); - expect(formatBytes(1000)).toBe('1,000B'); + expect(bytes(10)).toBe('10B'); + expect(bytes(100)).toBe('100B'); + expect(bytes(1000)).toBe('1,000B'); - expect(formatBytes(1 * 1024)).toBe('1KiB'); - expect(formatBytes(1 * 1024 ** 2)).toBe('1MiB'); - expect(formatBytes(1 * 1024 ** 3)).toBe('1GiB'); - }); + expect(bytes(1 * 1024)).toBe('1KiB'); + expect(bytes(1 * 1024 ** 2)).toBe('1MiB'); + expect(bytes(1 * 1024 ** 3)).toBe('1GiB'); + }); - it('kibibytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.kibibytes)(1)).toBe('1KiB'); - expect(getFormatter(SUPPORTED_FORMATS.kibibytes)(1, 1)).toBe('1.0KiB'); - }); + it('kibibytes', () => { + expect(kibibytes(1)).toBe('1KiB'); + expect(kibibytes(1, 1)).toBe('1.0KiB'); + }); - it('mebibytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.mebibytes)(1)).toBe('1MiB'); - expect(getFormatter(SUPPORTED_FORMATS.mebibytes)(1, 1)).toBe('1.0MiB'); - }); + it('mebibytes', () => { + expect(mebibytes(1)).toBe('1MiB'); + expect(mebibytes(1, 1)).toBe('1.0MiB'); + }); - it('gibibytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.gibibytes)(1)).toBe('1GiB'); - expect(getFormatter(SUPPORTED_FORMATS.gibibytes)(1, 1)).toBe('1.0GiB'); - }); + it('gibibytes', () => { + expect(gibibytes(1)).toBe('1GiB'); + expect(gibibytes(1, 1)).toBe('1.0GiB'); + }); - it('tebibytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.tebibytes)(1)).toBe('1TiB'); - expect(getFormatter(SUPPORTED_FORMATS.tebibytes)(1, 1)).toBe('1.0TiB'); - }); + it('tebibytes', () => { + expect(tebibytes(1)).toBe('1TiB'); + expect(tebibytes(1, 1)).toBe('1.0TiB'); + }); - it('pebibytes', () => { - expect(getFormatter(SUPPORTED_FORMATS.pebibytes)(1)).toBe('1PiB'); - expect(getFormatter(SUPPORTED_FORMATS.pebibytes)(1, 1)).toBe('1.0PiB'); - }); + it('pebibytes', () => { + expect(pebibytes(1)).toBe('1PiB'); + expect(pebibytes(1, 1)).toBe('1.0PiB'); }); - describe('when get formatter format is incorrect', () => { - it('formatter fails', () => { - expect(() => getFormatter('not-supported')(1)).toThrow(); + describe('getFormatter', () => { + it.each([ + [1], + [10], + [200], + [100], + [1000], + [10_000], + [100_000], + [1_000_000], + [10 ** 6], + [10 ** 9], + [0.1], + [0.5], + [0.888888], + ])('formatting functions yield the same result as getFormatter for %d', (value) => { + expect(number(value)).toBe(getFormatter(SUPPORTED_FORMATS.number)(value)); + expect(percent(value)).toBe(getFormatter(SUPPORTED_FORMATS.percent)(value)); + expect(percentHundred(value)).toBe(getFormatter(SUPPORTED_FORMATS.percentHundred)(value)); + + expect(seconds(value)).toBe(getFormatter(SUPPORTED_FORMATS.seconds)(value)); + expect(milliseconds(value)).toBe(getFormatter(SUPPORTED_FORMATS.milliseconds)(value)); + + expect(decimalBytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.decimalBytes)(value)); + expect(kilobytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.kilobytes)(value)); + expect(megabytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.megabytes)(value)); + expect(gigabytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.gigabytes)(value)); + expect(terabytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.terabytes)(value)); + expect(petabytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.petabytes)(value)); + + expect(bytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.bytes)(value)); + expect(kibibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.kibibytes)(value)); + expect(mebibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.mebibytes)(value)); + expect(gibibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.gibibytes)(value)); + expect(tebibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.tebibytes)(value)); + expect(pebibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.pebibytes)(value)); + + expect(engineering(value)).toBe(getFormatter(SUPPORTED_FORMATS.engineering)(value)); + }); + + describe('when get formatter format is incorrect', () => { + it('formatter fails', () => { + expect(() => getFormatter('not-supported')(1)).toThrow(); + }); }); }); }); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index b60ddea81ee..e12cd8b0e37 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -814,6 +814,14 @@ describe('URL utility', () => { ); }); + it('decodes URI when decodeURI=true', () => { + const url = 'https://gitlab.com/test'; + + expect(urlUtils.setUrlParams({ labels: ['foo', 'bar'] }, url, false, true, true)).toEqual( + 'https://gitlab.com/test?labels[]=foo&labels[]=bar', + ); + }); + it('removes all existing URL params and sets a new param when cleanParams=true', () => { const url = 'https://gitlab.com/test?group_id=gitlab-org&project_id=my-project'; diff --git a/spec/frontend/line_highlighter_spec.js b/spec/frontend/line_highlighter_spec.js index 8318f63ab3e..b5a0adc9d49 100644 --- a/spec/frontend/line_highlighter_spec.js +++ b/spec/frontend/line_highlighter_spec.js @@ -7,7 +7,6 @@ import LineHighlighter from '~/line_highlighter'; describe('LineHighlighter', () => { const testContext = {}; - preloadFixtures('static/line_highlighter.html'); const clickLine = (number, eventData = {}) => { if ($.isEmptyObject(eventData)) { return $(`#L${number}`).click(); diff --git a/spec/frontend/locale/index_spec.js b/spec/frontend/locale/index_spec.js index d65d7c195b2..a08be502735 100644 --- a/spec/frontend/locale/index_spec.js +++ b/spec/frontend/locale/index_spec.js @@ -1,5 +1,5 @@ import { setLanguage } from 'helpers/locale_helper'; -import { createDateTimeFormat, languageCode } from '~/locale'; +import { createDateTimeFormat, formatNumber, languageCode } from '~/locale'; describe('locale', () => { afterEach(() => setLanguage(null)); @@ -27,4 +27,68 @@ describe('locale', () => { expect(dateFormat.format(new Date(2015, 6, 3))).toBe('July 3, 2015'); }); }); + + describe('formatNumber', () => { + it('formats numbers', () => { + expect(formatNumber(1)).toBe('1'); + expect(formatNumber(12345)).toBe('12,345'); + }); + + it('formats bigint numbers', () => { + expect(formatNumber(123456789123456789n)).toBe('123,456,789,123,456,789'); + }); + + it('formats numbers with options', () => { + expect(formatNumber(1, { style: 'percent' })).toBe('100%'); + expect(formatNumber(1, { style: 'currency', currency: 'USD' })).toBe('$1.00'); + }); + + it('formats localized numbers', () => { + expect(formatNumber(12345, {}, 'es')).toBe('12.345'); + }); + + it('formats NaN', () => { + expect(formatNumber(NaN)).toBe('NaN'); + }); + + it('formats infinity', () => { + expect(formatNumber(Number.POSITIVE_INFINITY)).toBe('∞'); + }); + + it('formats negative infinity', () => { + expect(formatNumber(Number.NEGATIVE_INFINITY)).toBe('-∞'); + }); + + it('formats EPSILON', () => { + expect(formatNumber(Number.EPSILON)).toBe('0'); + }); + + describe('non-number values should pass through', () => { + it('undefined', () => { + expect(formatNumber(undefined)).toBe(undefined); + }); + + it('null', () => { + expect(formatNumber(null)).toBe(null); + }); + + it('arrays', () => { + expect(formatNumber([])).toEqual([]); + }); + + it('objects', () => { + expect(formatNumber({ a: 'b' })).toEqual({ a: 'b' }); + }); + }); + + describe('when in a different locale', () => { + beforeEach(() => { + setLanguage('es'); + }); + + it('formats localized numbers', () => { + expect(formatNumber(12345)).toBe('12.345'); + }); + }); + }); }); diff --git a/spec/frontend/members/components/avatars/user_avatar_spec.js b/spec/frontend/members/components/avatars/user_avatar_spec.js index 303c82582a3..3f4d9155c5d 100644 --- a/spec/frontend/members/components/avatars/user_avatar_spec.js +++ b/spec/frontend/members/components/avatars/user_avatar_spec.js @@ -1,21 +1,31 @@ import { GlAvatarLink, GlBadge } from '@gitlab/ui'; import { within } from '@testing-library/dom'; import { mount, createWrapper } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; import UserAvatar from '~/members/components/avatars/user_avatar.vue'; -import { member as memberMock, orphanedMember } from '../../mock_data'; +import { member as memberMock, member2faEnabled, orphanedMember } from '../../mock_data'; + +Vue.use(Vuex); describe('UserAvatar', () => { let wrapper; const { user } = memberMock; - const createComponent = (propsData = {}) => { + const createComponent = (propsData = {}, state = {}) => { wrapper = mount(UserAvatar, { propsData: { member: memberMock, isCurrentUser: false, ...propsData, }, + store: new Vuex.Store({ + state: { + canManageMembers: true, + ...state, + }, + }), }); }; @@ -69,9 +79,9 @@ describe('UserAvatar', () => { describe('badges', () => { it.each` - member | badgeText - ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${'Blocked'} - ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${'2FA'} + member | badgeText + ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${'Blocked'} + ${member2faEnabled} | ${'2FA'} `('renders the "$badgeText" badge', ({ member, badgeText }) => { createComponent({ member }); @@ -83,6 +93,12 @@ describe('UserAvatar', () => { expect(getByText("It's you").exists()).toBe(true); }); + + it('does not render 2FA badge when `canManageMembers` is `false`', () => { + createComponent({ member: member2faEnabled }, { canManageMembers: false }); + + expect(within(wrapper.element).queryByText('2FA')).toBe(null); + }); }); describe('user status', () => { diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js index fa324ce1cf9..6a73b2fcf8c 100644 --- a/spec/frontend/members/mock_data.js +++ b/spec/frontend/members/mock_data.js @@ -75,3 +75,5 @@ export const membersJsonString = JSON.stringify(members); export const directMember = { ...member, isDirectMember: true }; export const inheritedMember = { ...member, isDirectMember: false }; + +export const member2faEnabled = { ...member, user: { ...member.user, twoFactorEnabled: true } }; diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js index f447a4c4ee9..bfb5a4bc7d3 100644 --- a/spec/frontend/members/utils_spec.js +++ b/spec/frontend/members/utils_spec.js @@ -17,6 +17,7 @@ import { member as memberMock, directMember, inheritedMember, + member2faEnabled, group, invite, membersJsonString, @@ -30,7 +31,11 @@ const URL_HOST = 'https://localhost/'; describe('Members Utils', () => { describe('generateBadges', () => { it('has correct properties for each badge', () => { - const badges = generateBadges(memberMock, true); + const badges = generateBadges({ + member: memberMock, + isCurrentUser: true, + canManageMembers: true, + }); badges.forEach((badge) => { expect(badge).toEqual( @@ -44,12 +49,32 @@ describe('Members Utils', () => { }); it.each` - member | expected - ${memberMock} | ${{ show: true, text: "It's you", variant: 'success' }} - ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${{ show: true, text: 'Blocked', variant: 'danger' }} - ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${{ show: true, text: '2FA', variant: 'info' }} + member | expected + ${memberMock} | ${{ show: true, text: "It's you", variant: 'success' }} + ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${{ show: true, text: 'Blocked', variant: 'danger' }} + ${member2faEnabled} | ${{ show: true, text: '2FA', variant: 'info' }} `('returns expected output for "$expected.text" badge', ({ member, expected }) => { - expect(generateBadges(member, true)).toContainEqual(expect.objectContaining(expected)); + expect( + generateBadges({ member, isCurrentUser: true, canManageMembers: true }), + ).toContainEqual(expect.objectContaining(expected)); + }); + + describe('when `canManageMembers` argument is `false`', () => { + describe.each` + description | memberIsCurrentUser | expectedBadgeToBeShown + ${'is not the current user'} | ${false} | ${false} + ${'is the current user'} | ${true} | ${true} + `('when member is $description', ({ memberIsCurrentUser, expectedBadgeToBeShown }) => { + it(`sets 'show' to '${expectedBadgeToBeShown}' for 2FA badge`, () => { + const badges = generateBadges({ + member: member2faEnabled, + isCurrentUser: memberIsCurrentUser, + canManageMembers: false, + }); + + expect(badges.find((badge) => badge.text === '2FA').show).toBe(expectedBadgeToBeShown); + }); + }); }); }); diff --git a/spec/frontend/merge_conflicts/store/actions_spec.js b/spec/frontend/merge_conflicts/store/actions_spec.js new file mode 100644 index 00000000000..352f1783b87 --- /dev/null +++ b/spec/frontend/merge_conflicts/store/actions_spec.js @@ -0,0 +1,257 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import testAction from 'helpers/vuex_action_helper'; +import createFlash from '~/flash'; +import { INTERACTIVE_RESOLVE_MODE, EDIT_RESOLVE_MODE } from '~/merge_conflicts/constants'; +import * as actions from '~/merge_conflicts/store/actions'; +import * as types from '~/merge_conflicts/store/mutation_types'; +import { restoreFileLinesState, markLine, decorateFiles } from '~/merge_conflicts/utils'; + +jest.mock('~/flash.js'); +jest.mock('~/merge_conflicts/utils'); + +describe('merge conflicts actions', () => { + let mock; + + const files = [ + { + blobPath: 'a', + }, + { blobPath: 'b' }, + ]; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('fetchConflictsData', () => { + const conflictsPath = 'conflicts/path/mock'; + + it('on success dispatches setConflictsData', (done) => { + mock.onGet(conflictsPath).reply(200, {}); + testAction( + actions.fetchConflictsData, + conflictsPath, + {}, + [ + { type: types.SET_LOADING_STATE, payload: true }, + { type: types.SET_LOADING_STATE, payload: false }, + ], + [{ type: 'setConflictsData', payload: {} }], + done, + ); + }); + + it('when data has type equal to error ', (done) => { + mock.onGet(conflictsPath).reply(200, { type: 'error', message: 'error message' }); + testAction( + actions.fetchConflictsData, + conflictsPath, + {}, + [ + { type: types.SET_LOADING_STATE, payload: true }, + { type: types.SET_FAILED_REQUEST, payload: 'error message' }, + { type: types.SET_LOADING_STATE, payload: false }, + ], + [], + done, + ); + }); + + it('when request fails ', (done) => { + mock.onGet(conflictsPath).reply(400); + testAction( + actions.fetchConflictsData, + conflictsPath, + {}, + [ + { type: types.SET_LOADING_STATE, payload: true }, + { type: types.SET_FAILED_REQUEST }, + { type: types.SET_LOADING_STATE, payload: false }, + ], + [], + done, + ); + }); + }); + + describe('submitResolvedConflicts', () => { + useMockLocationHelper(); + const resolveConflictsPath = 'resolve/conflicts/path/mock'; + + it('on success reloads the page', (done) => { + mock.onPost(resolveConflictsPath).reply(200, { redirect_to: 'hrefPath' }); + testAction( + actions.submitResolvedConflicts, + resolveConflictsPath, + {}, + [{ type: types.SET_SUBMIT_STATE, payload: true }], + [], + () => { + expect(window.location.assign).toHaveBeenCalledWith('hrefPath'); + done(); + }, + ); + }); + + it('on errors shows flash', (done) => { + mock.onPost(resolveConflictsPath).reply(400); + testAction( + actions.submitResolvedConflicts, + resolveConflictsPath, + {}, + [ + { type: types.SET_SUBMIT_STATE, payload: true }, + { type: types.SET_SUBMIT_STATE, payload: false }, + ], + [], + () => { + expect(createFlash).toHaveBeenCalledWith({ + message: 'Failed to save merge conflicts resolutions. Please try again!', + }); + done(); + }, + ); + }); + }); + + describe('setConflictsData', () => { + it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => { + decorateFiles.mockReturnValue([{ bar: 'baz' }]); + testAction( + actions.setConflictsData, + { files, foo: 'bar' }, + {}, + [ + { + type: types.SET_CONFLICTS_DATA, + payload: { foo: 'bar', files: [{ bar: 'baz' }] }, + }, + ], + [], + done, + ); + }); + }); + + describe('setFileResolveMode', () => { + it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => { + testAction( + actions.setFileResolveMode, + { file: files[0], mode: INTERACTIVE_RESOLVE_MODE }, + { conflictsData: { files }, getFileIndex: () => 0 }, + [ + { + type: types.UPDATE_FILE, + payload: { + file: { ...files[0], showEditor: false, resolveMode: INTERACTIVE_RESOLVE_MODE }, + index: 0, + }, + }, + ], + [], + done, + ); + }); + + it('EDIT_RESOLVE_MODE updates the correct file ', (done) => { + restoreFileLinesState.mockReturnValue([]); + const file = { + ...files[0], + showEditor: true, + loadEditor: true, + resolutionData: {}, + resolveMode: EDIT_RESOLVE_MODE, + }; + testAction( + actions.setFileResolveMode, + { file: files[0], mode: EDIT_RESOLVE_MODE }, + { conflictsData: { files }, getFileIndex: () => 0 }, + [ + { + type: types.UPDATE_FILE, + payload: { + file, + index: 0, + }, + }, + ], + [], + () => { + expect(restoreFileLinesState).toHaveBeenCalledWith(file); + done(); + }, + ); + }); + }); + + describe('setPromptConfirmationState', () => { + it('updates the correct file ', (done) => { + testAction( + actions.setPromptConfirmationState, + { file: files[0], promptDiscardConfirmation: true }, + { conflictsData: { files }, getFileIndex: () => 0 }, + [ + { + type: types.UPDATE_FILE, + payload: { + file: { ...files[0], promptDiscardConfirmation: true }, + index: 0, + }, + }, + ], + [], + done, + ); + }); + }); + + describe('handleSelected', () => { + const file = { + ...files[0], + inlineLines: [{ id: 1, hasConflict: true }, { id: 2 }], + parallelLines: [ + [{ id: 1, hasConflict: true }, { id: 1 }], + [{ id: 2 }, { id: 3 }], + ], + }; + + it('updates the correct file ', (done) => { + const marLikeMockReturn = { foo: 'bar' }; + markLine.mockReturnValue(marLikeMockReturn); + + testAction( + actions.handleSelected, + { file, line: { id: 1, section: 'baz' } }, + { conflictsData: { files }, getFileIndex: () => 0 }, + [ + { + type: types.UPDATE_FILE, + payload: { + file: { + ...file, + resolutionData: { 1: 'baz' }, + inlineLines: [marLikeMockReturn, { id: 2 }], + parallelLines: [ + [marLikeMockReturn, marLikeMockReturn], + [{ id: 2 }, { id: 3 }], + ], + }, + index: 0, + }, + }, + ], + [], + () => { + expect(markLine).toHaveBeenCalledTimes(3); + done(); + }, + ); + }); + }); +}); diff --git a/spec/frontend/merge_request_spec.js b/spec/frontend/merge_request_spec.js index 84647a108b2..0b7ed349507 100644 --- a/spec/frontend/merge_request_spec.js +++ b/spec/frontend/merge_request_spec.js @@ -9,7 +9,6 @@ describe('MergeRequest', () => { describe('task lists', () => { let mock; - preloadFixtures('merge_requests/merge_request_with_task_list.html'); beforeEach(() => { loadFixtures('merge_requests/merge_request_with_task_list.html'); diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js index fd2c240aff3..23e9bf8b447 100644 --- a/spec/frontend/merge_request_tabs_spec.js +++ b/spec/frontend/merge_request_tabs_spec.js @@ -21,11 +21,6 @@ describe('MergeRequestTabs', () => { $.extend(stubLocation, defaults, stubs || {}); }; - preloadFixtures( - 'merge_requests/merge_request_with_task_list.html', - 'merge_requests/diff_comment.html', - ); - beforeEach(() => { initMrPage(); diff --git a/spec/frontend/mini_pipeline_graph_dropdown_spec.js b/spec/frontend/mini_pipeline_graph_dropdown_spec.js index 3ff34c967e4..ccd5a4ea142 100644 --- a/spec/frontend/mini_pipeline_graph_dropdown_spec.js +++ b/spec/frontend/mini_pipeline_graph_dropdown_spec.js @@ -5,8 +5,6 @@ import axios from '~/lib/utils/axios_utils'; import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; describe('Mini Pipeline Graph Dropdown', () => { - preloadFixtures('static/mini_dropdown_graph.html'); - beforeEach(() => { loadFixtures('static/mini_dropdown_graph.html'); }); diff --git a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js index b794d0c571e..400ac2e8f85 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js @@ -188,7 +188,7 @@ describe('dashboard invalid url parameters', () => { }); describe('when there is an error', () => { - const mockError = 'an error ocurred!'; + const mockError = 'an error occurred!'; beforeEach(() => { store.commit(`monitoringDashboard/${types.RECEIVE_PANEL_PREVIEW_FAILURE}`, mockError); diff --git a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js index 9672f6a315a..51b4106d4b1 100644 --- a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js +++ b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js @@ -23,7 +23,7 @@ describe('DuplicateDashboardForm', () => { findByRef(ref).setValue(val); }; const setChecked = (value) => { - const input = wrapper.find(`.form-check-input[value="${value}"]`); + const input = wrapper.find(`.custom-control-input[value="${value}"]`); input.element.checked = true; input.trigger('click'); input.trigger('change'); diff --git a/spec/frontend/monitoring/requests/index_spec.js b/spec/frontend/monitoring/requests/index_spec.js index b30b1e60575..03bf5d70153 100644 --- a/spec/frontend/monitoring/requests/index_spec.js +++ b/spec/frontend/monitoring/requests/index_spec.js @@ -94,7 +94,7 @@ describe('monitoring metrics_requests', () => { it('rejects after getting an HTTP 500 error', () => { mock.onGet(prometheusEndpoint).reply(500, { status: 'error', - error: 'An error ocurred', + error: 'An error occurred', }); return getPrometheusQueryData(prometheusEndpoint, params).catch((error) => { @@ -106,7 +106,7 @@ describe('monitoring metrics_requests', () => { // Mock multiple attempts while the cache is filling up and fails mock.onGet(prometheusEndpoint).reply(statusCodes.UNAUTHORIZED, { status: 'error', - error: 'An error ocurred', + error: 'An error occurred', }); return getPrometheusQueryData(prometheusEndpoint, params).catch((error) => { @@ -120,7 +120,7 @@ describe('monitoring metrics_requests', () => { mock.onGet(prometheusEndpoint).replyOnce(statusCodes.NO_CONTENT); mock.onGet(prometheusEndpoint).reply(500, { status: 'error', - error: 'An error ocurred', + error: 'An error occurred', }); // 3rd attempt return getPrometheusQueryData(prometheusEndpoint, params).catch((error) => { diff --git a/spec/frontend/new_branch_spec.js b/spec/frontend/new_branch_spec.js index 7e6b8a78d4f..66b28a8c0dc 100644 --- a/spec/frontend/new_branch_spec.js +++ b/spec/frontend/new_branch_spec.js @@ -9,8 +9,6 @@ describe('Branch', () => { }); describe('create a new branch', () => { - preloadFixtures('branches/new_branch.html'); - function fillNameWith(value) { $('.js-branch-name').val(value).trigger('blur'); } diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 2f58f75ab70..bab90723578 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -1,7 +1,9 @@ +import { GlDropdown, GlAlert } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import Autosize from 'autosize'; import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import Vuex from 'vuex'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { deprecatedCreateFlash as flash } from '~/flash'; @@ -9,7 +11,8 @@ import axios from '~/lib/utils/axios_utils'; import CommentForm from '~/notes/components/comment_form.vue'; import * as constants from '~/notes/constants'; import eventHub from '~/notes/event_hub'; -import createStore from '~/notes/stores'; +import { COMMENT_FORM } from '~/notes/i18n'; +import notesModule from '~/notes/stores/modules'; import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data'; jest.mock('autosize'); @@ -17,15 +20,45 @@ jest.mock('~/commons/nav/user_merge_requests'); jest.mock('~/flash'); jest.mock('~/gl_form'); +Vue.use(Vuex); + describe('issue_comment_form component', () => { let store; let wrapper; let axiosMock; const findCloseReopenButton = () => wrapper.findByTestId('close-reopen-button'); - const findCommentButton = () => wrapper.findByTestId('comment-button'); const findTextArea = () => wrapper.findByTestId('comment-field'); const findConfidentialNoteCheckbox = () => wrapper.findByTestId('confidential-note-checkbox'); + const findCommentGlDropdown = () => wrapper.find(GlDropdown); + const findCommentButton = () => findCommentGlDropdown().find('button'); + const findErrorAlerts = () => wrapper.findAllComponents(GlAlert).wrappers; + + async function clickCommentButton({ waitForComponent = true, waitForNetwork = true } = {}) { + findCommentButton().trigger('click'); + + if (waitForComponent || waitForNetwork) { + // Wait for the click to bubble out and trigger the handler + await nextTick(); + + if (waitForNetwork) { + // Wait for the network request promise to resolve + await nextTick(); + } + } + } + + function createStore({ actions = {} } = {}) { + const baseModule = notesModule(); + + return new Vuex.Store({ + ...baseModule, + actions: { + ...baseModule.actions, + ...actions, + }, + }); + } const createNotableDataMock = (data = {}) => { return { @@ -101,6 +134,83 @@ describe('issue_comment_form component', () => { expect(wrapper.vm.resizeTextarea).toHaveBeenCalled(); }); + it('does not report errors in the UI when the save succeeds', async () => { + mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' } }); + + jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); + + await clickCommentButton(); + + // findErrorAlerts().exists returns false if *any* wrapper is empty, + // not necessarily that there aren't any at all. + // We want to check here that there are none found, so we use the + // raw wrapper array length instead. + expect(findErrorAlerts().length).toBe(0); + }); + + it.each` + httpStatus | errors + ${400} | ${[COMMENT_FORM.GENERIC_UNSUBMITTABLE_NETWORK]} + ${422} | ${['error 1']} + ${422} | ${['error 1', 'error 2']} + ${422} | ${['error 1', 'error 2', 'error 3']} + `( + 'displays the correct errors ($errors) for a $httpStatus network response', + async ({ errors, httpStatus }) => { + store = createStore({ + actions: { + saveNote: jest.fn().mockRejectedValue({ + response: { status: httpStatus, data: { errors: { commands_only: errors } } }, + }), + }, + }); + + mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' } }); + + await clickCommentButton(); + + const errorAlerts = findErrorAlerts(); + + expect(errorAlerts.length).toBe(errors.length); + errors.forEach((msg, index) => { + const alert = errorAlerts[index]; + + expect(alert.text()).toBe(msg); + }); + }, + ); + + it('should remove the correct error from the list when it is dismissed', async () => { + const commandErrors = ['1', '2', '3']; + store = createStore({ + actions: { + saveNote: jest.fn().mockRejectedValue({ + response: { status: 422, data: { errors: { commands_only: [...commandErrors] } } }, + }), + }, + }); + + mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' } }); + + await clickCommentButton(); + + let errorAlerts = findErrorAlerts(); + + expect(errorAlerts.length).toBe(commandErrors.length); + + // dismiss the second error + extendedWrapper(errorAlerts[1]).findByTestId('close-icon').trigger('click'); + // Wait for the dismissal to bubble out of the Alert component and be handled in this component + await nextTick(); + // Refresh the list of alerts + errorAlerts = findErrorAlerts(); + + expect(errorAlerts.length).toBe(commandErrors.length - 1); + // We want to know that the *correct* error was dismissed, not just that any one is gone + expect(errorAlerts[0].text()).toBe(commandErrors[0]); + expect(errorAlerts[1].text()).toBe(commandErrors[2]); + }); + it('should toggle issue state when no note', () => { mountComponent({ mountFunction: mount }); @@ -243,7 +353,7 @@ describe('issue_comment_form component', () => { it('should render comment button as disabled', () => { mountComponent(); - expect(findCommentButton().props('disabled')).toBe(true); + expect(findCommentGlDropdown().props('disabled')).toBe(true); }); it('should enable comment button if it has note', async () => { @@ -251,7 +361,7 @@ describe('issue_comment_form component', () => { await wrapper.setData({ note: 'Foo' }); - expect(findCommentButton().props('disabled')).toBe(false); + expect(findCommentGlDropdown().props('disabled')).toBe(false); }); it('should update buttons texts when it has note', () => { @@ -437,7 +547,7 @@ describe('issue_comment_form component', () => { await wrapper.vm.$nextTick(); // submit comment - wrapper.findByTestId('comment-button').trigger('click'); + findCommentButton().trigger('click'); const [providedData] = wrapper.vm.saveNote.mock.calls[0]; expect(providedData.data.note.confidential).toBe(shouldCheckboxBeChecked); @@ -472,16 +582,4 @@ describe('issue_comment_form component', () => { expect(findTextArea().exists()).toBe(false); }); }); - - describe('close/reopen button variants', () => { - it.each([ - [constants.OPENED, 'warning'], - [constants.REOPENED, 'warning'], - [constants.CLOSED, 'default'], - ])('when %s, the variant of the btn is %s', (state, expected) => { - mountComponent({ noteableData: { ...noteableDataMock, state } }); - - expect(findCloseReopenButton().props('variant')).toBe(expected); - }); - }); }); diff --git a/spec/frontend/notes/components/diff_discussion_header_spec.js b/spec/frontend/notes/components/diff_discussion_header_spec.js index fdc89522901..fa34a5e8d39 100644 --- a/spec/frontend/notes/components/diff_discussion_header_spec.js +++ b/spec/frontend/notes/components/diff_discussion_header_spec.js @@ -6,14 +6,10 @@ import createStore from '~/notes/stores'; import mockDiffFile from '../../diffs/mock_data/diff_discussions'; import { discussionMock } from '../mock_data'; -const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; - describe('diff_discussion_header component', () => { let store; let wrapper; - preloadFixtures(discussionWithTwoUnresolvedNotes); - beforeEach(() => { window.mrTabs = {}; store = createStore(); diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js index 03e5842bb0f..c6a7d7ead98 100644 --- a/spec/frontend/notes/components/discussion_actions_spec.js +++ b/spec/frontend/notes/components/discussion_actions_spec.js @@ -96,7 +96,7 @@ describe('DiscussionActions', () => { it('emits showReplyForm event when clicking on reply placeholder', () => { jest.spyOn(wrapper.vm, '$emit'); - wrapper.find(ReplyPlaceholder).find('button').trigger('click'); + wrapper.find(ReplyPlaceholder).find('textarea').trigger('focus'); expect(wrapper.vm.$emit).toHaveBeenCalledWith('showReplyForm'); }); diff --git a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js index b7b7ec08867..2a4cd0df0c7 100644 --- a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js +++ b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js @@ -1,17 +1,17 @@ import { shallowMount } from '@vue/test-utils'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; -const buttonText = 'Test Button Text'; +const placeholderText = 'Test Button Text'; describe('ReplyPlaceholder', () => { let wrapper; - const findButton = () => wrapper.find({ ref: 'button' }); + const findTextarea = () => wrapper.find({ ref: 'textarea' }); beforeEach(() => { wrapper = shallowMount(ReplyPlaceholder, { propsData: { - buttonText, + placeholderText, }, }); }); @@ -20,17 +20,17 @@ describe('ReplyPlaceholder', () => { wrapper.destroy(); }); - it('emits onClick event on button click', () => { - findButton().trigger('click'); + it('emits focus event on button click', () => { + findTextarea().trigger('focus'); return wrapper.vm.$nextTick().then(() => { expect(wrapper.emitted()).toEqual({ - onClick: [[]], + focus: [[]], }); }); }); it('should render reply button', () => { - expect(findButton().text()).toEqual(buttonText); + expect(findTextarea().attributes('placeholder')).toEqual(placeholderText); }); }); diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js index 17717ebd09a..cc41088e21e 100644 --- a/spec/frontend/notes/components/note_actions_spec.js +++ b/spec/frontend/notes/components/note_actions_spec.js @@ -6,6 +6,7 @@ import axios from '~/lib/utils/axios_utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import noteActions from '~/notes/components/note_actions.vue'; import createStore from '~/notes/stores'; +import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import { userDataMock } from '../mock_data'; describe('noteActions', () => { @@ -15,6 +16,9 @@ describe('noteActions', () => { let actions; let axiosMock; + const findUserAccessRoleBadge = (idx) => wrapper.findAll(UserAccessRoleBadge).at(idx); + const findUserAccessRoleBadgeText = (idx) => findUserAccessRoleBadge(idx).text().trim(); + const mountNoteActions = (propsData, computed) => { const localVue = createLocalVue(); return mount(localVue.extend(noteActions), { @@ -44,6 +48,7 @@ describe('noteActions', () => { projectName: 'project', reportAbusePath: `${TEST_HOST}/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26`, showReply: false, + awardPath: `${TEST_HOST}/award_emoji`, }; actions = { @@ -66,11 +71,11 @@ describe('noteActions', () => { }); it('should render noteable author badge', () => { - expect(wrapper.findAll('.note-role').at(0).text().trim()).toEqual('Author'); + expect(findUserAccessRoleBadgeText(0)).toBe('Author'); }); it('should render access level badge', () => { - expect(wrapper.findAll('.note-role').at(1).text().trim()).toEqual(props.accessLevel); + expect(findUserAccessRoleBadgeText(1)).toBe(props.accessLevel); }); it('should render contributor badge', () => { @@ -80,7 +85,7 @@ describe('noteActions', () => { }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.findAll('.note-role').at(1).text().trim()).toBe('Contributor'); + expect(findUserAccessRoleBadgeText(1)).toBe('Contributor'); }); }); diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index 7615f3b70f1..92137d3190f 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -83,7 +83,7 @@ describe('issue_note_form component', () => { }); const message = - 'This comment has changed since you started editing, please review the updated comment to ensure information is not lost.'; + 'This comment changed after you started editing it. Review the updated comment to ensure information is not lost.'; await nextTick(); diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js index 87538279c3d..dd65351ef88 100644 --- a/spec/frontend/notes/components/noteable_discussion_spec.js +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -24,8 +24,6 @@ describe('noteable_discussion component', () => { let wrapper; let originalGon; - preloadFixtures(discussionWithTwoUnresolvedNotes); - beforeEach(() => { window.mrTabs = {}; store = createStore(); @@ -65,7 +63,7 @@ describe('noteable_discussion component', () => { expect(wrapper.vm.isReplying).toEqual(false); const replyPlaceholder = wrapper.find(ReplyPlaceholder); - replyPlaceholder.vm.$emit('onClick'); + replyPlaceholder.vm.$emit('focus'); await nextTick(); expect(wrapper.vm.isReplying).toEqual(true); diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js index fe78e086403..112983f3ac2 100644 --- a/spec/frontend/notes/components/noteable_note_spec.js +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -1,5 +1,6 @@ import { mount, createLocalVue } from '@vue/test-utils'; import { escape } from 'lodash'; +import waitForPromises from 'helpers/wait_for_promises'; import NoteActions from '~/notes/components/note_actions.vue'; import NoteBody from '~/notes/components/note_body.vue'; import NoteHeader from '~/notes/components/note_header.vue'; @@ -13,7 +14,7 @@ describe('issue_note', () => { let wrapper; const findMultilineComment = () => wrapper.find('[data-testid="multiline-comment"]'); - beforeEach(() => { + const createWrapper = (props = {}) => { store = createStore(); store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNotesData', notesDataMock); @@ -23,6 +24,7 @@ describe('issue_note', () => { store, propsData: { note, + ...props, }, localVue, stubs: [ @@ -33,14 +35,18 @@ describe('issue_note', () => { 'multiline-comment-form', ], }); - }); + }; afterEach(() => { wrapper.destroy(); }); describe('mutiline comments', () => { - it('should render if has multiline comment', () => { + beforeEach(() => { + createWrapper(); + }); + + it('should render if has multiline comment', async () => { const position = { line_range: { start: { @@ -69,9 +75,8 @@ describe('issue_note', () => { line, }); - return wrapper.vm.$nextTick().then(() => { - expect(findMultilineComment().text()).toEqual('Comment on lines 1 to 2'); - }); + await wrapper.vm.$nextTick(); + expect(findMultilineComment().text()).toBe('Comment on lines 1 to 2'); }); it('should only render if it has everything it needs', () => { @@ -147,108 +152,151 @@ describe('issue_note', () => { }); }); - it('should render user information', () => { - const { author } = note; - const avatar = wrapper.find(UserAvatarLink); - const avatarProps = avatar.props(); + describe('rendering', () => { + beforeEach(() => { + createWrapper(); + }); - expect(avatarProps.linkHref).toBe(author.path); - expect(avatarProps.imgSrc).toBe(author.avatar_url); - expect(avatarProps.imgAlt).toBe(author.name); - expect(avatarProps.imgSize).toBe(40); - }); + it('should render user information', () => { + const { author } = note; + const avatar = wrapper.findComponent(UserAvatarLink); + const avatarProps = avatar.props(); - it('should render note header content', () => { - const noteHeader = wrapper.find(NoteHeader); - const noteHeaderProps = noteHeader.props(); + expect(avatarProps.linkHref).toBe(author.path); + expect(avatarProps.imgSrc).toBe(author.avatar_url); + expect(avatarProps.imgAlt).toBe(author.name); + expect(avatarProps.imgSize).toBe(40); + }); - expect(noteHeaderProps.author).toEqual(note.author); - expect(noteHeaderProps.createdAt).toEqual(note.created_at); - expect(noteHeaderProps.noteId).toEqual(note.id); - }); + it('should render note header content', () => { + const noteHeader = wrapper.findComponent(NoteHeader); + const noteHeaderProps = noteHeader.props(); - it('should render note actions', () => { - const { author } = note; - const noteActions = wrapper.find(NoteActions); - const noteActionsProps = noteActions.props(); - - expect(noteActionsProps.authorId).toBe(author.id); - expect(noteActionsProps.noteId).toBe(note.id); - expect(noteActionsProps.noteUrl).toBe(note.noteable_note_url); - expect(noteActionsProps.accessLevel).toBe(note.human_access); - expect(noteActionsProps.canEdit).toBe(note.current_user.can_edit); - expect(noteActionsProps.canAwardEmoji).toBe(note.current_user.can_award_emoji); - expect(noteActionsProps.canDelete).toBe(note.current_user.can_edit); - expect(noteActionsProps.canReportAsAbuse).toBe(true); - expect(noteActionsProps.canResolve).toBe(false); - expect(noteActionsProps.reportAbusePath).toBe(note.report_abuse_path); - expect(noteActionsProps.resolvable).toBe(false); - expect(noteActionsProps.isResolved).toBe(false); - expect(noteActionsProps.isResolving).toBe(false); - expect(noteActionsProps.resolvedBy).toEqual({}); - }); + expect(noteHeaderProps.author).toBe(note.author); + expect(noteHeaderProps.createdAt).toBe(note.created_at); + expect(noteHeaderProps.noteId).toBe(note.id); + }); - it('should render issue body', () => { - const noteBody = wrapper.find(NoteBody); - const noteBodyProps = noteBody.props(); + it('should render note actions', () => { + const { author } = note; + const noteActions = wrapper.findComponent(NoteActions); + const noteActionsProps = noteActions.props(); - expect(noteBodyProps.note).toEqual(note); - expect(noteBodyProps.line).toBe(null); - expect(noteBodyProps.canEdit).toBe(note.current_user.can_edit); - expect(noteBodyProps.isEditing).toBe(false); - expect(noteBodyProps.helpPagePath).toBe(''); - }); + expect(noteActionsProps.authorId).toBe(author.id); + expect(noteActionsProps.noteId).toBe(note.id); + expect(noteActionsProps.noteUrl).toBe(note.noteable_note_url); + expect(noteActionsProps.accessLevel).toBe(note.human_access); + expect(noteActionsProps.canEdit).toBe(note.current_user.can_edit); + expect(noteActionsProps.canAwardEmoji).toBe(note.current_user.can_award_emoji); + expect(noteActionsProps.canDelete).toBe(note.current_user.can_edit); + expect(noteActionsProps.canReportAsAbuse).toBe(true); + expect(noteActionsProps.canResolve).toBe(false); + expect(noteActionsProps.reportAbusePath).toBe(note.report_abuse_path); + expect(noteActionsProps.resolvable).toBe(false); + expect(noteActionsProps.isResolved).toBe(false); + expect(noteActionsProps.isResolving).toBe(false); + expect(noteActionsProps.resolvedBy).toEqual({}); + }); - it('prevents note preview xss', (done) => { - const imgSrc = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; - const noteBody = `<img src="${imgSrc}" onload="alert(1)" />`; - const alertSpy = jest.spyOn(window, 'alert'); - store.hotUpdate({ - actions: { - updateNote() {}, - setSelectedCommentPositionHover() {}, - }, + it('should render issue body', () => { + const noteBody = wrapper.findComponent(NoteBody); + const noteBodyProps = noteBody.props(); + + expect(noteBodyProps.note).toBe(note); + expect(noteBodyProps.line).toBe(null); + expect(noteBodyProps.canEdit).toBe(note.current_user.can_edit); + expect(noteBodyProps.isEditing).toBe(false); + expect(noteBodyProps.helpPagePath).toBe(''); }); - const noteBodyComponent = wrapper.find(NoteBody); - noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {}); + it('prevents note preview xss', async () => { + const noteBody = + '<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" onload="alert(1)" />'; + const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {}); + const noteBodyComponent = wrapper.findComponent(NoteBody); + + store.hotUpdate({ + actions: { + updateNote() {}, + setSelectedCommentPositionHover() {}, + }, + }); + + noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {}); - setImmediate(() => { + await waitForPromises(); expect(alertSpy).not.toHaveBeenCalled(); - expect(wrapper.vm.note.note_html).toEqual(escape(noteBody)); - done(); + expect(wrapper.vm.note.note_html).toBe(escape(noteBody)); }); }); describe('cancel edit', () => { - it('restores content of updated note', (done) => { + beforeEach(() => { + createWrapper(); + }); + + it('restores content of updated note', async () => { const updatedText = 'updated note text'; store.hotUpdate({ actions: { updateNote() {}, }, }); - const noteBody = wrapper.find(NoteBody); + const noteBody = wrapper.findComponent(NoteBody); noteBody.vm.resetAutoSave = () => {}; noteBody.vm.$emit('handleFormUpdate', updatedText, null, () => {}); - wrapper.vm - .$nextTick() - .then(() => { - const noteBodyProps = noteBody.props(); - - expect(noteBodyProps.note.note_html).toBe(updatedText); - noteBody.vm.$emit('cancelForm'); - }) - .then(() => wrapper.vm.$nextTick()) - .then(() => { - const noteBodyProps = noteBody.props(); - - expect(noteBodyProps.note.note_html).toBe(note.note_html); - }) - .then(done) - .catch(done.fail); + await wrapper.vm.$nextTick(); + let noteBodyProps = noteBody.props(); + + expect(noteBodyProps.note.note_html).toBe(updatedText); + + noteBody.vm.$emit('cancelForm'); + await wrapper.vm.$nextTick(); + + noteBodyProps = noteBody.props(); + + expect(noteBodyProps.note.note_html).toBe(note.note_html); + }); + }); + + describe('formUpdateHandler', () => { + const updateNote = jest.fn(); + const params = ['', null, jest.fn(), '']; + + const updateActions = () => { + store.hotUpdate({ + actions: { + updateNote, + setSelectedCommentPositionHover() {}, + }, + }); + }; + + afterEach(() => updateNote.mockReset()); + + it('responds to handleFormUpdate', () => { + createWrapper(); + updateActions(); + wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params); + expect(wrapper.emitted('handleUpdateNote')).toBeTruthy(); + }); + + it('does not stringify empty position', () => { + createWrapper(); + updateActions(); + wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params); + expect(updateNote.mock.calls[0][1].note.note.position).toBeUndefined(); + }); + + it('stringifies populated position', () => { + const position = { test: true }; + const expectation = JSON.stringify(position); + createWrapper({ note: { ...note, position } }); + updateActions(); + wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params); + expect(updateNote.mock.calls[0][1].note.note.position).toBe(expectation); }); }); }); diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index efee72dea96..163501d5ce8 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -33,6 +33,8 @@ describe('note_app', () => { let wrapper; let store; + const findCommentButton = () => wrapper.find('[data-testid="comment-button"]'); + const getComponentOrder = () => { return wrapper .findAll('#notes-list,.js-comment-form') @@ -144,7 +146,7 @@ describe('note_app', () => { }); it('should render form comment button as disabled', () => { - expect(wrapper.find('.js-note-new-discussion').attributes('disabled')).toEqual('disabled'); + expect(findCommentButton().props('disabled')).toEqual(true); }); it('updates discussions badge', () => { diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 1852108b39f..f972ff0d2e4 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -3,6 +3,7 @@ import testAction from 'helpers/vuex_action_helper'; import { TEST_HOST } from 'spec/test_constants'; import Api from '~/api'; import { deprecatedCreateFlash as Flash } from '~/flash'; +import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; import * as notesConstants from '~/notes/constants'; import createStore from '~/notes/stores'; @@ -10,7 +11,6 @@ import * as actions from '~/notes/stores/actions'; import * as mutationTypes from '~/notes/stores/mutation_types'; import mutations from '~/notes/stores/mutations'; import * as utils from '~/notes/stores/utils'; -import updateIssueConfidentialMutation from '~/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql'; import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql'; import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql'; import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub'; @@ -203,7 +203,7 @@ describe('Actions Notes Store', () => { describe('emitStateChangedEvent', () => { it('emits an event on the document', () => { - document.addEventListener('issuable_vue_app:change', (event) => { + document.addEventListener(EVENT_ISSUABLE_VUE_APP_CHANGE, (event) => { expect(event.detail.data).toEqual({ id: '1', state: 'closed' }); expect(event.detail.isClosed).toEqual(false); }); @@ -1276,68 +1276,6 @@ describe('Actions Notes Store', () => { }); }); - describe('updateConfidentialityOnIssuable', () => { - state = { noteableData: { confidential: false } }; - const iid = '1'; - const projectPath = 'full/path'; - const getters = { getNoteableData: { iid } }; - const actionArgs = { fullPath: projectPath, confidential: true }; - const confidential = true; - - beforeEach(() => { - jest - .spyOn(utils.gqClient, 'mutate') - .mockResolvedValue({ data: { issueSetConfidential: { issue: { confidential } } } }); - }); - - it('calls gqClient mutation one time', () => { - actions.updateConfidentialityOnIssuable({ commit: () => {}, state, getters }, actionArgs); - - expect(utils.gqClient.mutate).toHaveBeenCalledTimes(1); - }); - - it('calls gqClient mutation with the correct values', () => { - actions.updateConfidentialityOnIssuable({ commit: () => {}, state, getters }, actionArgs); - - expect(utils.gqClient.mutate).toHaveBeenCalledWith({ - mutation: updateIssueConfidentialMutation, - variables: { input: { iid, projectPath, confidential } }, - }); - }); - - describe('on success of mutation', () => { - it('calls commit with the correct values', () => { - const commitSpy = jest.fn(); - - return actions - .updateConfidentialityOnIssuable({ commit: commitSpy, state, getters }, actionArgs) - .then(() => { - expect(Flash).not.toHaveBeenCalled(); - expect(commitSpy).toHaveBeenCalledWith( - mutationTypes.SET_ISSUE_CONFIDENTIAL, - confidential, - ); - }); - }); - }); - - describe('on user recoverable error', () => { - it('sends the error to Flash', () => { - const error = 'error'; - - jest - .spyOn(utils.gqClient, 'mutate') - .mockResolvedValue({ data: { issueSetConfidential: { errors: [error] } } }); - - return actions - .updateConfidentialityOnIssuable({ commit: () => {}, state, getters }, actionArgs) - .then(() => { - expect(Flash).toHaveBeenCalledWith(error, 'alert'); - }); - }); - }); - }); - describe.each` issuableType ${'issue'} | ${'merge_request'} diff --git a/spec/frontend/notes/stores/getters_spec.js b/spec/frontend/notes/stores/getters_spec.js index 4ebfc679310..4d2f86a1ecf 100644 --- a/spec/frontend/notes/stores/getters_spec.js +++ b/spec/frontend/notes/stores/getters_spec.js @@ -26,8 +26,6 @@ const createDiscussionNeighborParams = (discussionId, diffOrder, step) => ({ describe('Getters Notes Store', () => { let state; - preloadFixtures(discussionWithTwoUnresolvedNotes); - beforeEach(() => { state = { discussions: [individualNote], diff --git a/spec/frontend/notifications/components/custom_notifications_modal_spec.js b/spec/frontend/notifications/components/custom_notifications_modal_spec.js index 3e87f3107bd..5e4114d91f5 100644 --- a/spec/frontend/notifications/components/custom_notifications_modal_spec.js +++ b/spec/frontend/notifications/components/custom_notifications_modal_spec.js @@ -180,7 +180,7 @@ describe('CustomNotificationsModal', () => { expect( mockToastShow, ).toHaveBeenCalledWith( - 'An error occured while loading the notification settings. Please try again.', + 'An error occurred while loading the notification settings. Please try again.', { type: 'error' }, ); }); @@ -258,7 +258,7 @@ describe('CustomNotificationsModal', () => { expect( mockToastShow, ).toHaveBeenCalledWith( - 'An error occured while updating the notification settings. Please try again.', + 'An error occurred while updating the notification settings. Please try again.', { type: 'error' }, ); }); diff --git a/spec/frontend/notifications/components/notifications_dropdown_spec.js b/spec/frontend/notifications/components/notifications_dropdown_spec.js index 0673fb51a91..e90bd68d067 100644 --- a/spec/frontend/notifications/components/notifications_dropdown_spec.js +++ b/spec/frontend/notifications/components/notifications_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlButtonGroup, GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; @@ -15,14 +15,10 @@ const mockToastShow = jest.fn(); describe('NotificationsDropdown', () => { let wrapper; let mockAxios; - let glModalDirective; function createComponent(injectedProperties = {}) { - glModalDirective = jest.fn(); - return shallowMount(NotificationsDropdown, { stubs: { - GlButtonGroup, GlDropdown, GlDropdownItem, NotificationsDropdownItem, @@ -30,11 +26,6 @@ describe('NotificationsDropdown', () => { }, directives: { GlTooltip: createMockDirective(), - glModal: { - bind(_, { value }) { - glModalDirective(value); - }, - }, }, provide: { dropdownItems: mockDropdownItems, @@ -49,13 +40,12 @@ describe('NotificationsDropdown', () => { }); } - const findButtonGroup = () => wrapper.find(GlButtonGroup); - const findButton = () => wrapper.find(GlButton); const findDropdown = () => wrapper.find(GlDropdown); const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); const findAllNotificationsDropdownItems = () => wrapper.findAll(NotificationsDropdownItem); const findDropdownItemAt = (index) => findAllNotificationsDropdownItems().at(index).find(GlDropdownItem); + const findNotificationsModal = () => wrapper.find(CustomNotificationsModal); const clickDropdownItemAt = async (index) => { const dropdownItem = findDropdownItemAt(index); @@ -83,8 +73,8 @@ describe('NotificationsDropdown', () => { }); }); - it('renders a button group', () => { - expect(findButtonGroup().exists()).toBe(true); + it('renders split dropdown', () => { + expect(findDropdown().props().split).toBe(true); }); it('shows the button text when showLabel is true', () => { @@ -93,7 +83,7 @@ describe('NotificationsDropdown', () => { showLabel: true, }); - expect(findButton().text()).toBe('Custom'); + expect(findDropdown().props().text).toBe('Custom'); }); it("doesn't show the button text when showLabel is false", () => { @@ -102,7 +92,7 @@ describe('NotificationsDropdown', () => { showLabel: false, }); - expect(findButton().text()).toBe(''); + expect(findDropdown().props().text).toBe(null); }); it('opens the modal when the user clicks the button', async () => { @@ -113,9 +103,9 @@ describe('NotificationsDropdown', () => { initialNotificationLevel: 'custom', }); - findButton().vm.$emit('click'); + await findDropdown().vm.$emit('click'); - expect(glModalDirective).toHaveBeenCalled(); + expect(findNotificationsModal().props().visible).toBe(true); }); }); @@ -126,8 +116,8 @@ describe('NotificationsDropdown', () => { }); }); - it('does not render a button group', () => { - expect(findButtonGroup().exists()).toBe(false); + it('renders unified dropdown', () => { + expect(findDropdown().props().split).toBe(false); }); it('shows the button text when showLabel is true', () => { @@ -162,7 +152,7 @@ describe('NotificationsDropdown', () => { initialNotificationLevel: level, }); - const tooltipElement = findByTestId('notificationButton'); + const tooltipElement = findByTestId('notification-dropdown'); const tooltip = getBinding(tooltipElement.element, 'gl-tooltip'); expect(tooltip.value.title).toBe(`${tooltipTitlePrefix} - ${title}`); @@ -255,7 +245,7 @@ describe('NotificationsDropdown', () => { expect( mockToastShow, ).toHaveBeenCalledWith( - 'An error occured while updating the notification settings. Please try again.', + 'An error occurred while updating the notification settings. Please try again.', { type: 'error' }, ); }); @@ -264,11 +254,9 @@ describe('NotificationsDropdown', () => { mockAxios.onPut('/api/v4/notification_settings').reply(httpStatus.OK, {}); wrapper = createComponent(); - const mockModalShow = jest.spyOn(wrapper.vm.$refs.customNotificationsModal, 'open'); - await clickDropdownItemAt(5); - expect(mockModalShow).toHaveBeenCalled(); + expect(findNotificationsModal().props().visible).toBe(true); }); }); }); diff --git a/spec/frontend/oauth_remember_me_spec.js b/spec/frontend/oauth_remember_me_spec.js index 910676a97ed..70bda1d9f9e 100644 --- a/spec/frontend/oauth_remember_me_spec.js +++ b/spec/frontend/oauth_remember_me_spec.js @@ -6,8 +6,6 @@ describe('OAuthRememberMe', () => { return $(`#oauth-container .oauth-login${selector}`).parent('form').attr('action'); }; - preloadFixtures('static/oauth_remember_me.html'); - beforeEach(() => { loadFixtures('static/oauth_remember_me.html'); diff --git a/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap index a1d08f032bc..a3423e3f4d7 100644 --- a/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap @@ -2,11 +2,10 @@ exports[`ConanInstallation renders all the messages 1`] = ` <div> - <h3 - class="gl-font-lg" - > - Installation - </h3> + <installation-title-stub + options="[object Object]" + packagetype="conan" + /> <code-instruction-stub copytext="Copy Conan Command" diff --git a/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap index 6d22b372d41..a6bb9e868ee 100644 --- a/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap @@ -1,12 +1,38 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`MavenInstallation renders all the messages 1`] = ` +exports[`MavenInstallation gradle renders all the messages 1`] = ` <div> - <h3 - class="gl-font-lg" - > - Installation - </h3> + <installation-title-stub + options="[object Object],[object Object]" + packagetype="maven" + /> + + <code-instruction-stub + class="gl-mb-5" + copytext="Copy Gradle Groovy DSL install command" + instruction="foo/gradle/install" + label="Gradle Groovy DSL install command" + trackingaction="copy_gradle_install_command" + trackinglabel="code_instruction" + /> + + <code-instruction-stub + copytext="Copy add Gradle Groovy DSL repository command" + instruction="foo/gradle/add/source" + label="Add Gradle Groovy DSL repository command" + multiline="true" + trackingaction="copy_gradle_add_to_source_command" + trackinglabel="code_instruction" + /> +</div> +`; + +exports[`MavenInstallation maven renders all the messages 1`] = ` +<div> + <installation-title-stub + options="[object Object],[object Object]" + packagetype="maven" + /> <p> <gl-sprintf-stub @@ -17,7 +43,7 @@ exports[`MavenInstallation renders all the messages 1`] = ` <code-instruction-stub copytext="Copy Maven XML" instruction="foo/xml" - label="Maven XML" + label="" multiline="true" trackingaction="copy_maven_xml" trackinglabel="code_instruction" diff --git a/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap index b616751f75f..6903d342d6a 100644 --- a/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap @@ -2,11 +2,10 @@ exports[`NpmInstallation renders all the messages 1`] = ` <div> - <h3 - class="gl-font-lg" - > - Installation - </h3> + <installation-title-stub + options="[object Object]" + packagetype="npm" + /> <code-instruction-stub copytext="Copy npm command" diff --git a/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap index 84cf5e4db49..04532743952 100644 --- a/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap @@ -2,11 +2,10 @@ exports[`NugetInstallation renders all the messages 1`] = ` <div> - <h3 - class="gl-font-lg" - > - Installation - </h3> + <installation-title-stub + options="[object Object]" + packagetype="nuget" + /> <code-instruction-stub copytext="Copy NuGet Command" diff --git a/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap index 2a588f99c1d..d5bb825d8d1 100644 --- a/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap @@ -2,11 +2,10 @@ exports[`PypiInstallation renders all the messages 1`] = ` <div> - <h3 - class="gl-font-lg" - > - Installation - </h3> + <installation-title-stub + options="[object Object]" + packagetype="pypi" + /> <code-instruction-stub copytext="Copy Pip command" diff --git a/spec/frontend/packages/details/components/composer_installation_spec.js b/spec/frontend/packages/details/components/composer_installation_spec.js index a1d30d0ed22..18d11c7dd57 100644 --- a/spec/frontend/packages/details/components/composer_installation_spec.js +++ b/spec/frontend/packages/details/components/composer_installation_spec.js @@ -4,6 +4,7 @@ import Vuex from 'vuex'; import { registryUrl as composerHelpPath } from 'jest/packages/details/mock_data'; import { composerPackage as packageEntity } from 'jest/packages/mock_data'; import ComposerInstallation from '~/packages/details/components/composer_installation.vue'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import { TrackingActions } from '~/packages/details/constants'; @@ -33,6 +34,7 @@ describe('ComposerInstallation', () => { const findPackageInclude = () => wrapper.find('[data-testid="package-include"]'); const findHelpText = () => wrapper.find('[data-testid="help-text"]'); const findHelpLink = () => wrapper.find(GlLink); + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); function createComponent() { wrapper = shallowMount(ComposerInstallation, { @@ -48,6 +50,19 @@ describe('ComposerInstallation', () => { wrapper.destroy(); }); + describe('install command switch', () => { + it('has the installation title component', () => { + createStore(); + createComponent(); + + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: 'composer', + options: [{ value: 'composer', label: 'Show Composer commands' }], + }); + }); + }); + describe('registry include command', () => { beforeEach(() => { createStore(); diff --git a/spec/frontend/packages/details/components/conan_installation_spec.js b/spec/frontend/packages/details/components/conan_installation_spec.js index bf8a92a6350..78a7d265a21 100644 --- a/spec/frontend/packages/details/components/conan_installation_spec.js +++ b/spec/frontend/packages/details/components/conan_installation_spec.js @@ -1,6 +1,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import ConanInstallation from '~/packages/details/components/conan_installation.vue'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; import { conanPackage as packageEntity } from '../../mock_data'; import { registryUrl as conanPath } from '../mock_data'; @@ -26,6 +27,7 @@ describe('ConanInstallation', () => { }); const findCodeInstructions = () => wrapper.findAll(CodeInstructions); + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); function createComponent() { wrapper = shallowMount(ConanInstallation, { @@ -39,13 +41,23 @@ describe('ConanInstallation', () => { }); afterEach(() => { - if (wrapper) wrapper.destroy(); + wrapper.destroy(); }); it('renders all the messages', () => { expect(wrapper.element).toMatchSnapshot(); }); + describe('install command switch', () => { + it('has the installation title component', () => { + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: 'conan', + options: [{ value: 'conan', label: 'Show Conan commands' }], + }); + }); + }); + describe('installation commands', () => { it('renders the correct command', () => { expect(findCodeInstructions().at(0).props('instruction')).toBe(conanInstallationCommandStr); diff --git a/spec/frontend/packages/details/components/installation_title_spec.js b/spec/frontend/packages/details/components/installation_title_spec.js new file mode 100644 index 00000000000..14e990d3011 --- /dev/null +++ b/spec/frontend/packages/details/components/installation_title_spec.js @@ -0,0 +1,58 @@ +import { shallowMount } from '@vue/test-utils'; + +import InstallationTitle from '~/packages/details/components/installation_title.vue'; +import PersistedDropdownSelection from '~/vue_shared/components/registry/persisted_dropdown_selection.vue'; + +describe('InstallationTitle', () => { + let wrapper; + + const defaultProps = { packageType: 'foo', options: [{ value: 'foo', label: 'bar' }] }; + + const findPersistedDropdownSelection = () => wrapper.findComponent(PersistedDropdownSelection); + const findTitle = () => wrapper.find('h3'); + + function createComponent({ props = {} } = {}) { + wrapper = shallowMount(InstallationTitle, { + propsData: { + ...defaultProps, + ...props, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('has a title', () => { + createComponent(); + + expect(findTitle().exists()).toBe(true); + expect(findTitle().text()).toBe('Installation'); + }); + + describe('persisted dropdown selection', () => { + it('exists', () => { + createComponent(); + + expect(findPersistedDropdownSelection().exists()).toBe(true); + }); + + it('has the correct props', () => { + createComponent(); + + expect(findPersistedDropdownSelection().props()).toMatchObject({ + storageKey: 'package_foo_installation_instructions', + options: defaultProps.options, + }); + }); + + it('on change event emits a change event', () => { + createComponent(); + + findPersistedDropdownSelection().vm.$emit('change', 'baz'); + + expect(wrapper.emitted('change')).toEqual([['baz']]); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/maven_installation_spec.js b/spec/frontend/packages/details/components/maven_installation_spec.js index dfeb6002186..d49a7c0b561 100644 --- a/spec/frontend/packages/details/components/maven_installation_spec.js +++ b/spec/frontend/packages/details/components/maven_installation_spec.js @@ -1,7 +1,9 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; import Vuex from 'vuex'; import { registryUrl as mavenPath } from 'jest/packages/details/mock_data'; import { mavenPackage as packageEntity } from 'jest/packages/mock_data'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import MavenInstallation from '~/packages/details/components/maven_installation.vue'; import { TrackingActions } from '~/packages/details/constants'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; @@ -15,6 +17,8 @@ describe('MavenInstallation', () => { const xmlCodeBlock = 'foo/xml'; const mavenCommandStr = 'foo/command'; const mavenSetupXml = 'foo/setup'; + const gradleGroovyInstallCommandText = 'foo/gradle/install'; + const gradleGroovyAddSourceCommandText = 'foo/gradle/add/source'; const store = new Vuex.Store({ state: { @@ -25,54 +29,120 @@ describe('MavenInstallation', () => { mavenInstallationXml: () => xmlCodeBlock, mavenInstallationCommand: () => mavenCommandStr, mavenSetupXml: () => mavenSetupXml, + gradleGroovyInstalCommand: () => gradleGroovyInstallCommandText, + gradleGroovyAddSourceCommand: () => gradleGroovyAddSourceCommandText, }, }); const findCodeInstructions = () => wrapper.findAll(CodeInstructions); + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); - function createComponent() { + function createComponent({ data = {} } = {}) { wrapper = shallowMount(MavenInstallation, { localVue, store, + data() { + return data; + }, }); } - beforeEach(() => { - createComponent(); - }); - afterEach(() => { - if (wrapper) wrapper.destroy(); + wrapper.destroy(); }); - it('renders all the messages', () => { - expect(wrapper.element).toMatchSnapshot(); + describe('install command switch', () => { + it('has the installation title component', () => { + createComponent(); + + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: 'maven', + options: [ + { value: 'maven', label: 'Show Maven commands' }, + { value: 'groovy', label: 'Show Gradle Groovy DSL commands' }, + ], + }); + }); + + it('on change event updates the instructions to show', async () => { + createComponent(); + + expect(findCodeInstructions().at(0).props('instruction')).toBe(xmlCodeBlock); + findInstallationTitle().vm.$emit('change', 'groovy'); + + await nextTick(); + + expect(findCodeInstructions().at(0).props('instruction')).toBe( + gradleGroovyInstallCommandText, + ); + }); }); - describe('installation commands', () => { - it('renders the correct xml block', () => { - expect(findCodeInstructions().at(0).props()).toMatchObject({ - instruction: xmlCodeBlock, - multiline: true, - trackingAction: TrackingActions.COPY_MAVEN_XML, + describe('maven', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('installation commands', () => { + it('renders the correct xml block', () => { + expect(findCodeInstructions().at(0).props()).toMatchObject({ + instruction: xmlCodeBlock, + multiline: true, + trackingAction: TrackingActions.COPY_MAVEN_XML, + }); + }); + + it('renders the correct maven command', () => { + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: mavenCommandStr, + multiline: false, + trackingAction: TrackingActions.COPY_MAVEN_COMMAND, + }); }); }); - it('renders the correct maven command', () => { - expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: mavenCommandStr, - multiline: false, - trackingAction: TrackingActions.COPY_MAVEN_COMMAND, + describe('setup commands', () => { + it('renders the correct xml block', () => { + expect(findCodeInstructions().at(2).props()).toMatchObject({ + instruction: mavenSetupXml, + multiline: true, + trackingAction: TrackingActions.COPY_MAVEN_SETUP, + }); }); }); }); - describe('setup commands', () => { - it('renders the correct xml block', () => { - expect(findCodeInstructions().at(2).props()).toMatchObject({ - instruction: mavenSetupXml, - multiline: true, - trackingAction: TrackingActions.COPY_MAVEN_SETUP, + describe('gradle', () => { + beforeEach(() => { + createComponent({ data: { instructionType: 'gradle' } }); + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('installation commands', () => { + it('renders the gradle install command', () => { + expect(findCodeInstructions().at(0).props()).toMatchObject({ + instruction: gradleGroovyInstallCommandText, + multiline: false, + trackingAction: TrackingActions.COPY_GRADLE_INSTALL_COMMAND, + }); + }); + }); + + describe('setup commands', () => { + it('renders the correct gradle command', () => { + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: gradleGroovyAddSourceCommandText, + multiline: true, + trackingAction: TrackingActions.COPY_GRADLE_ADD_TO_SOURCE_COMMAND, + }); }); }); }); diff --git a/spec/frontend/packages/details/components/npm_installation_spec.js b/spec/frontend/packages/details/components/npm_installation_spec.js index df820e7e948..09afcd4fd0a 100644 --- a/spec/frontend/packages/details/components/npm_installation_spec.js +++ b/spec/frontend/packages/details/components/npm_installation_spec.js @@ -2,6 +2,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { registryUrl as nugetPath } from 'jest/packages/details/mock_data'; import { npmPackage as packageEntity } from 'jest/packages/mock_data'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import NpmInstallation from '~/packages/details/components/npm_installation.vue'; import { TrackingActions } from '~/packages/details/constants'; import { npmInstallationCommand, npmSetupCommand } from '~/packages/details/store/getters'; @@ -14,6 +15,7 @@ describe('NpmInstallation', () => { let wrapper; const findCodeInstructions = () => wrapper.findAll(CodeInstructions); + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); function createComponent() { const store = new Vuex.Store({ @@ -38,13 +40,23 @@ describe('NpmInstallation', () => { }); afterEach(() => { - if (wrapper) wrapper.destroy(); + wrapper.destroy(); }); it('renders all the messages', () => { expect(wrapper.element).toMatchSnapshot(); }); + describe('install command switch', () => { + it('has the installation title component', () => { + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: 'npm', + options: [{ value: 'npm', label: 'Show NPM commands' }], + }); + }); + }); + describe('installation commands', () => { it('renders the correct npm command', () => { expect(findCodeInstructions().at(0).props()).toMatchObject({ diff --git a/spec/frontend/packages/details/components/nuget_installation_spec.js b/spec/frontend/packages/details/components/nuget_installation_spec.js index 100e369751c..8839a8f1108 100644 --- a/spec/frontend/packages/details/components/nuget_installation_spec.js +++ b/spec/frontend/packages/details/components/nuget_installation_spec.js @@ -2,6 +2,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { registryUrl as nugetPath } from 'jest/packages/details/mock_data'; import { nugetPackage as packageEntity } from 'jest/packages/mock_data'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import NugetInstallation from '~/packages/details/components/nuget_installation.vue'; import { TrackingActions } from '~/packages/details/constants'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; @@ -27,6 +28,7 @@ describe('NugetInstallation', () => { }); const findCodeInstructions = () => wrapper.findAll(CodeInstructions); + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); function createComponent() { wrapper = shallowMount(NugetInstallation, { @@ -40,13 +42,23 @@ describe('NugetInstallation', () => { }); afterEach(() => { - if (wrapper) wrapper.destroy(); + wrapper.destroy(); }); it('renders all the messages', () => { expect(wrapper.element).toMatchSnapshot(); }); + describe('install command switch', () => { + it('has the installation title component', () => { + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: 'nuget', + options: [{ value: 'nuget', label: 'Show Nuget commands' }], + }); + }); + }); + describe('installation commands', () => { it('renders the correct command', () => { expect(findCodeInstructions().at(0).props()).toMatchObject({ diff --git a/spec/frontend/packages/details/components/pypi_installation_spec.js b/spec/frontend/packages/details/components/pypi_installation_spec.js index a6ccba71554..2cec84282d9 100644 --- a/spec/frontend/packages/details/components/pypi_installation_spec.js +++ b/spec/frontend/packages/details/components/pypi_installation_spec.js @@ -1,6 +1,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { pypiPackage as packageEntity } from 'jest/packages/mock_data'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import PypiInstallation from '~/packages/details/components/pypi_installation.vue'; const localVue = createLocalVue(); @@ -26,6 +27,8 @@ describe('PypiInstallation', () => { const pipCommand = () => wrapper.find('[data-testid="pip-command"]'); const setupInstruction = () => wrapper.find('[data-testid="pypi-setup-content"]'); + const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + function createComponent() { wrapper = shallowMount(PypiInstallation, { localVue, @@ -39,7 +42,16 @@ describe('PypiInstallation', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; + }); + + describe('install command switch', () => { + it('has the installation title component', () => { + expect(findInstallationTitle().exists()).toBe(true); + expect(findInstallationTitle().props()).toMatchObject({ + packageType: 'pypi', + options: [{ value: 'pypi', label: 'Show PyPi commands' }], + }); + }); }); it('renders all the messages', () => { diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js index 07c120f57f7..f12b75d3b70 100644 --- a/spec/frontend/packages/details/store/getters_spec.js +++ b/spec/frontend/packages/details/store/getters_spec.js @@ -17,6 +17,8 @@ import { composerRegistryInclude, composerPackageInclude, groupExists, + gradleGroovyInstalCommand, + gradleGroovyAddSourceCommand, } from '~/packages/details/store/getters'; import { conanPackage, @@ -99,7 +101,7 @@ describe('Getters PackageDetails Store', () => { packageEntity | expectedResult ${conanPackage} | ${'Conan'} ${packageWithoutBuildInfo} | ${'Maven'} - ${npmPackage} | ${'NPM'} + ${npmPackage} | ${'npm'} ${nugetPackage} | ${'NuGet'} ${pypiPackage} | ${'PyPI'} `(`package type`, ({ packageEntity, expectedResult }) => { @@ -168,13 +170,13 @@ describe('Getters PackageDetails Store', () => { }); describe('npm string getters', () => { - it('gets the correct npmInstallationCommand for NPM', () => { + it('gets the correct npmInstallationCommand for npm', () => { setupState({ packageEntity: npmPackage }); expect(npmInstallationCommand(state)(NpmManager.NPM)).toBe(npmInstallStr); }); - it('gets the correct npmSetupCommand for NPM', () => { + it('gets the correct npmSetupCommand for npm', () => { setupState({ packageEntity: npmPackage }); expect(npmSetupCommand(state)(NpmManager.NPM)).toBe(npmSetupStr); @@ -235,6 +237,26 @@ describe('Getters PackageDetails Store', () => { }); }); + describe('gradle groovy string getters', () => { + it('gets the correct gradleGroovyInstalCommand', () => { + setupState(); + + expect(gradleGroovyInstalCommand(state)).toMatchInlineSnapshot( + `"implementation 'com.test.app:test-app:1.0-SNAPSHOT'"`, + ); + }); + + it('gets the correct gradleGroovyAddSourceCommand', () => { + setupState(); + + expect(gradleGroovyAddSourceCommand(state)).toMatchInlineSnapshot(` + "maven { + url 'foo/registry' + }" + `); + }); + }); + describe('check if group', () => { it('is set', () => { setupState({ groupListUrl: '/groups/composer/-/packages' }); diff --git a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap index 4a75deebcf9..77095f7c611 100644 --- a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap +++ b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap @@ -6,7 +6,7 @@ exports[`packages_list_row renders 1`] = ` data-qa-selector="package_row" > <div - class="gl-display-flex gl-align-items-center gl-py-3" + class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5" > <!----> @@ -102,7 +102,7 @@ exports[`packages_list_row renders 1`] = ` <gl-button-stub aria-label="Remove package" buttontextclasses="" - category="primary" + category="secondary" data-testid="action-delete" icon="remove" size="medium" diff --git a/spec/frontend/packages/shared/components/package_list_row_spec.js b/spec/frontend/packages/shared/components/package_list_row_spec.js index bd122167273..1c0ef7e3539 100644 --- a/spec/frontend/packages/shared/components/package_list_row_spec.js +++ b/spec/frontend/packages/shared/components/package_list_row_spec.js @@ -60,11 +60,9 @@ describe('packages_list_row', () => { }); describe('when is is group', () => { - beforeEach(() => { + it('has a package path component', () => { mountComponent({ isGroup: true }); - }); - it('has a package path component', () => { expect(findPackagePath().exists()).toBe(true); expect(findPackagePath().props()).toMatchObject({ path: 'foo/bar/baz' }); }); @@ -92,10 +90,22 @@ describe('packages_list_row', () => { }); }); - describe('delete event', () => { - beforeEach(() => mountComponent({ packageEntity: packageWithoutTags })); + describe('delete button', () => { + it('exists and has the correct props', () => { + mountComponent({ packageEntity: packageWithoutTags }); + + expect(findDeleteButton().exists()).toBe(true); + expect(findDeleteButton().attributes()).toMatchObject({ + icon: 'remove', + category: 'secondary', + variant: 'danger', + title: 'Remove package', + }); + }); it('emits the packageToDelete event when the delete button is clicked', async () => { + mountComponent({ packageEntity: packageWithoutTags }); + findDeleteButton().vm.$emit('click'); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/packages/shared/utils_spec.js b/spec/frontend/packages/shared/utils_spec.js index 506f37f8895..4a95def1bef 100644 --- a/spec/frontend/packages/shared/utils_spec.js +++ b/spec/frontend/packages/shared/utils_spec.js @@ -35,7 +35,7 @@ describe('Packages shared utils', () => { packageType | expectedResult ${'conan'} | ${'Conan'} ${'maven'} | ${'Maven'} - ${'npm'} | ${'NPM'} + ${'npm'} | ${'npm'} ${'nuget'} | ${'NuGet'} ${'pypi'} | ${'PyPI'} ${'composer'} | ${'Composer'} diff --git a/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js b/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js index 2c76adf761f..71c9da238b4 100644 --- a/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js +++ b/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js @@ -14,8 +14,6 @@ describe('Abuse Reports', () => { const findMessage = (searchText) => $messages.filter((index, element) => element.innerText.indexOf(searchText) > -1).first(); - preloadFixtures(FIXTURE); - beforeEach(() => { loadFixtures(FIXTURE); new AbuseReports(); // eslint-disable-line no-new diff --git a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js index 8816609d1d2..9f326dc33c0 100644 --- a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js +++ b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js @@ -8,7 +8,6 @@ describe('AccountAndLimits', () => { const FIXTURE = 'application_settings/accounts_and_limit.html'; let $userDefaultExternal; let $userInternalRegex; - preloadFixtures(FIXTURE); beforeEach(() => { loadFixtures(FIXTURE); diff --git a/spec/frontend/pages/admin/users/new/index_spec.js b/spec/frontend/pages/admin/users/new/index_spec.js index 60482860e84..ec9fe487030 100644 --- a/spec/frontend/pages/admin/users/new/index_spec.js +++ b/spec/frontend/pages/admin/users/new/index_spec.js @@ -7,8 +7,6 @@ describe('UserInternalRegexHandler', () => { let $userEmail; let $warningMessage; - preloadFixtures(FIXTURE); - beforeEach(() => { loadFixtures(FIXTURE); // eslint-disable-next-line no-new diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js index fb612f17669..de8b29d54fc 100644 --- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js +++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js @@ -14,7 +14,6 @@ const TEST_COUNT_BIG = 2000; const TEST_DONE_COUNT_BIG = 7300; describe('Todos', () => { - preloadFixtures('todos/todos.html'); let todoItem; let mock; diff --git a/spec/frontend/pages/projects/forks/new/components/app_spec.js b/spec/frontend/pages/projects/forks/new/components/app_spec.js new file mode 100644 index 00000000000..e1820606704 --- /dev/null +++ b/spec/frontend/pages/projects/forks/new/components/app_spec.js @@ -0,0 +1,42 @@ +import { shallowMount } from '@vue/test-utils'; +import App from '~/pages/projects/forks/new/components/app.vue'; + +describe('App component', () => { + let wrapper; + + const DEFAULT_PROPS = { + forkIllustration: 'illustrations/project-create-new-sm.svg', + endpoint: '/some/project-full-path/-/forks/new.json', + projectFullPath: '/some/project-full-path', + projectId: '10', + projectName: 'Project Name', + projectPath: 'project-name', + projectDescription: 'some project description', + projectVisibility: 'private', + }; + + const createComponent = (props = {}) => { + wrapper = shallowMount(App, { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays the correct svg illustration', () => { + expect(wrapper.find('img').attributes('src')).toBe('illustrations/project-create-new-sm.svg'); + }); + + it('renders ForkForm component with prop', () => { + expect(wrapper.props()).toEqual(expect.objectContaining(DEFAULT_PROPS)); + }); +}); diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js new file mode 100644 index 00000000000..694a0c2b9c1 --- /dev/null +++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js @@ -0,0 +1,275 @@ +import { GlForm, GlFormInputGroup } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import createFlash from '~/flash'; +import httpStatus from '~/lib/utils/http_status'; +import * as urlUtility from '~/lib/utils/url_utility'; +import ForkForm from '~/pages/projects/forks/new/components/fork_form.vue'; + +jest.mock('~/flash'); +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +describe('ForkForm component', () => { + let wrapper; + let axiosMock; + + const GON_GITLAB_URL = 'https://gitlab.com'; + const GON_API_VERSION = 'v7'; + + const MOCK_NAMESPACES_RESPONSE = [ + { + name: 'one', + id: 1, + }, + { + name: 'two', + id: 2, + }, + ]; + + const DEFAULT_PROPS = { + endpoint: '/some/project-full-path/-/forks/new.json', + projectFullPath: '/some/project-full-path', + projectId: '10', + projectName: 'Project Name', + projectPath: 'project-name', + projectDescription: 'some project description', + projectVisibility: 'private', + }; + + const mockGetRequest = (data = {}, statusCode = httpStatus.OK) => { + axiosMock.onGet(DEFAULT_PROPS.endpoint).replyOnce(statusCode, data); + }; + + const createComponent = (props = {}, data = {}) => { + wrapper = shallowMount(ForkForm, { + provide: { + newGroupPath: 'some/groups/path', + visibilityHelpPath: 'some/visibility/help/path', + }, + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + data() { + return { + ...data, + }; + }, + stubs: { + GlFormInputGroup, + }, + }); + }; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + window.gon = { + gitlab_url: GON_GITLAB_URL, + api_version: GON_API_VERSION, + }; + }); + + afterEach(() => { + wrapper.destroy(); + axiosMock.restore(); + }); + + const findPrivateRadio = () => wrapper.find('[data-testid="radio-private"]'); + const findInternalRadio = () => wrapper.find('[data-testid="radio-internal"]'); + const findPublicRadio = () => wrapper.find('[data-testid="radio-public"]'); + const findForkNameInput = () => wrapper.find('[data-testid="fork-name-input"]'); + const findForkUrlInput = () => wrapper.find('[data-testid="fork-url-input"]'); + const findForkSlugInput = () => wrapper.find('[data-testid="fork-slug-input"]'); + const findForkDescriptionTextarea = () => + wrapper.find('[data-testid="fork-description-textarea"]'); + const findVisibilityRadioGroup = () => + wrapper.find('[data-testid="fork-visibility-radio-group"]'); + + it('will go to projectFullPath when click cancel button', () => { + mockGetRequest(); + createComponent(); + + const { projectFullPath } = DEFAULT_PROPS; + const cancelButton = wrapper.find('[data-testid="cancel-button"]'); + + expect(cancelButton.attributes('href')).toBe(projectFullPath); + }); + + it('make POST request with project param', async () => { + jest.spyOn(axios, 'post'); + + const namespaceId = 20; + + mockGetRequest(); + createComponent( + {}, + { + selectedNamespace: { + id: namespaceId, + }, + }, + ); + + wrapper.find(GlForm).vm.$emit('submit', { preventDefault: () => {} }); + + const { + projectId, + projectDescription, + projectName, + projectPath, + projectVisibility, + } = DEFAULT_PROPS; + + const url = `/api/${GON_API_VERSION}/projects/${projectId}/fork`; + const project = { + description: projectDescription, + id: projectId, + name: projectName, + namespace_id: namespaceId, + path: projectPath, + visibility: projectVisibility, + }; + + expect(axios.post).toHaveBeenCalledWith(url, project); + }); + + it('has input with csrf token', () => { + mockGetRequest(); + createComponent(); + + expect(wrapper.find('input[name="authenticity_token"]').attributes('value')).toBe( + 'mock-csrf-token', + ); + }); + + it('pre-populate form from project props', () => { + mockGetRequest(); + createComponent(); + + expect(findForkNameInput().attributes('value')).toBe(DEFAULT_PROPS.projectName); + expect(findForkSlugInput().attributes('value')).toBe(DEFAULT_PROPS.projectPath); + expect(findForkDescriptionTextarea().attributes('value')).toBe( + DEFAULT_PROPS.projectDescription, + ); + }); + + it('sets project URL prepend text with gon.gitlab_url', () => { + mockGetRequest(); + createComponent(); + + expect(wrapper.find(GlFormInputGroup).text()).toContain(`${GON_GITLAB_URL}/`); + }); + + it('will have required attribute for required fields', () => { + mockGetRequest(); + createComponent(); + + expect(findForkNameInput().attributes('required')).not.toBeUndefined(); + expect(findForkUrlInput().attributes('required')).not.toBeUndefined(); + expect(findForkSlugInput().attributes('required')).not.toBeUndefined(); + expect(findVisibilityRadioGroup().attributes('required')).not.toBeUndefined(); + expect(findForkDescriptionTextarea().attributes('required')).toBeUndefined(); + }); + + describe('forks namespaces', () => { + beforeEach(() => { + mockGetRequest({ namespaces: MOCK_NAMESPACES_RESPONSE }); + createComponent(); + }); + + it('make GET request from endpoint', async () => { + await axios.waitForAll(); + + expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROPS.endpoint); + }); + + it('generate default option', async () => { + await axios.waitForAll(); + + const optionsArray = findForkUrlInput().findAll('option'); + + expect(optionsArray.at(0).text()).toBe('Select a namespace'); + }); + + it('populate project url namespace options', async () => { + await axios.waitForAll(); + + const optionsArray = findForkUrlInput().findAll('option'); + + expect(optionsArray).toHaveLength(MOCK_NAMESPACES_RESPONSE.length + 1); + expect(optionsArray.at(1).text()).toBe(MOCK_NAMESPACES_RESPONSE[0].name); + expect(optionsArray.at(2).text()).toBe(MOCK_NAMESPACES_RESPONSE[1].name); + }); + }); + + describe('visibility level', () => { + it.each` + project | namespace | privateIsDisabled | internalIsDisabled | publicIsDisabled + ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} + ${'private'} | ${'internal'} | ${undefined} | ${'true'} | ${'true'} + ${'private'} | ${'public'} | ${undefined} | ${'true'} | ${'true'} + ${'internal'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} + ${'internal'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} + ${'internal'} | ${'public'} | ${undefined} | ${undefined} | ${'true'} + ${'public'} | ${'private'} | ${undefined} | ${'true'} | ${'true'} + ${'public'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'} + ${'public'} | ${'public'} | ${undefined} | ${undefined} | ${undefined} + `( + 'sets appropriate radio button disabled state', + async ({ project, namespace, privateIsDisabled, internalIsDisabled, publicIsDisabled }) => { + mockGetRequest(); + createComponent( + { + projectVisibility: project, + }, + { + selectedNamespace: { + visibility: namespace, + }, + }, + ); + + expect(findPrivateRadio().attributes('disabled')).toBe(privateIsDisabled); + expect(findInternalRadio().attributes('disabled')).toBe(internalIsDisabled); + expect(findPublicRadio().attributes('disabled')).toBe(publicIsDisabled); + }, + ); + }); + + describe('onSubmit', () => { + beforeEach(() => { + jest.spyOn(urlUtility, 'redirectTo').mockImplementation(); + }); + + it('redirect to POST web_url response', async () => { + const webUrl = `new/fork-project`; + + jest.spyOn(axios, 'post').mockResolvedValue({ data: { web_url: webUrl } }); + + mockGetRequest(); + createComponent(); + + await wrapper.vm.onSubmit(); + + expect(urlUtility.redirectTo).toHaveBeenCalledWith(webUrl); + }); + + it('display flash when POST is unsuccessful', async () => { + const dummyError = 'Fork project failed'; + + jest.spyOn(axios, 'post').mockRejectedValue(dummyError); + + mockGetRequest(); + createComponent(); + + await wrapper.vm.onSubmit(); + + expect(urlUtility.redirectTo).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledWith({ + message: dummyError, + }); + }); + }); +}); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap index c9141d13a46..1c1327e7a4e 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap +++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap @@ -4,7 +4,7 @@ exports[`Learn GitLab Design A should render the loading state 1`] = ` <ul> <li> <span> - Create a repository + Create or import a repository </span> </li> <li> @@ -14,7 +14,11 @@ exports[`Learn GitLab Design A should render the loading state 1`] = ` </li> <li> <span> - Set-up CI/CD + <gl-link-stub + href="http://example.com/" + > + Set up CI/CD + </gl-link-stub> </span> </li> <li> @@ -22,7 +26,7 @@ exports[`Learn GitLab Design A should render the loading state 1`] = ` <gl-link-stub href="http://example.com/" > - Start a free trial of GitLab Gold + Start a free Ultimate trial </gl-link-stub> </span> </li> @@ -40,7 +44,7 @@ exports[`Learn GitLab Design A should render the loading state 1`] = ` <gl-link-stub href="http://example.com/" > - Enable require merge approvals + Add merge request approval </gl-link-stub> </span> </li> @@ -49,7 +53,7 @@ exports[`Learn GitLab Design A should render the loading state 1`] = ` <gl-link-stub href="http://example.com/" > - Submit a merge request (MR) + Submit a merge request </gl-link-stub> </span> </li> @@ -58,7 +62,7 @@ exports[`Learn GitLab Design A should render the loading state 1`] = ` <gl-link-stub href="http://example.com/" > - Run a Security scan using CI/CD + Run a security scan </gl-link-stub> </span> </li> diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap index 85e3b675e5b..dd899b93302 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap +++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap @@ -1,66 +1,519 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Learn GitLab Design B should render the loading state 1`] = ` -<ul> - <li> - <span> - Create a repository - </span> - </li> - <li> - <span> - Invite your colleagues - </span> - </li> - <li> - <span> - Set-up CI/CD - </span> - </li> - <li> - <span> - <gl-link-stub - href="http://example.com/" +exports[`Learn GitLab Design B renders correctly 1`] = ` +<div> + <div + class="row" + > + <div + class="gl-mb-7 col-md-8 col-lg-7" + > + <h1 + class="gl-font-size-h1" > - Start a free trial of GitLab Gold - </gl-link-stub> - </span> - </li> - <li> - <span> - <gl-link-stub - href="http://example.com/" + Learn GitLab + </h1> + + <p + class="gl-text-gray-700 gl-mb-0" > - Add code owners - </gl-link-stub> - </span> - </li> - <li> - <span> - <gl-link-stub - href="http://example.com/" + Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project. + </p> + </div> + </div> + + <div + class="gl-mb-3" + > + <p + class="gl-text-gray-500 gl-mb-2" + data-testid="completion-percentage" + > + 25% completed + </p> + + <div + class="progress" + max="8" + value="2" + > + <div + aria-valuemax="8" + aria-valuemin="0" + aria-valuenow="2" + class="progress-bar" + role="progressbar" + style="width: 25%;" > - Enable require merge approvals - </gl-link-stub> - </span> - </li> - <li> - <span> - <gl-link-stub - href="http://example.com/" + <!----> + </div> + </div> + </div> + + <h2 + class="gl-font-lg gl-mb-3" + > + Set up your workspace + </h2> + + <p + class="gl-text-gray-700 gl-mb-6" + > + Complete these tasks first so you can enjoy GitLab's features to their fullest: + </p> + + <div + class="row row-cols-2 row-cols-md-3 row-cols-lg-4" + > + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0" > - Submit a merge request (MR) - </gl-link-stub> - </span> - </li> - <li> - <span> - <gl-link-stub - href="http://example.com/" + <!----> + + <div + class="gl-card-body" + > + <div + class="gl-text-right gl-h-5" + > + <svg + aria-hidden="true" + class="gl-text-green-500 gl-icon s16" + data-testid="completed-icon" + > + <use + href="#check-circle-filled" + /> + </svg> + </div> + + <div + class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" + > + <img + src="http://example.com/images/illustration.svg" + /> + + <h6> + Invite your colleagues + </h6> + + <p + class="gl-font-sm gl-text-gray-700" + > + GitLab works best as a team. Invite your colleague to enjoy all features. + </p> + + <a + class="gl-link" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Invite your colleagues + </a> + </div> + </div> + + <!----> + </div> + </div> + + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0" > - Run a Security scan using CI/CD - </gl-link-stub> - </span> - </li> -</ul> + <!----> + + <div + class="gl-card-body" + > + <div + class="gl-text-right gl-h-5" + > + <svg + aria-hidden="true" + class="gl-text-green-500 gl-icon s16" + data-testid="completed-icon" + > + <use + href="#check-circle-filled" + /> + </svg> + </div> + + <div + class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" + > + <img + src="http://example.com/images/illustration.svg" + /> + + <h6> + Create or import a repository + </h6> + + <p + class="gl-font-sm gl-text-gray-700" + > + Create or import your first repository into your new project. + </p> + + <a + class="gl-link" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Create or import a repository + </a> + </div> + </div> + + <!----> + </div> + </div> + + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0" + > + <!----> + + <div + class="gl-card-body" + > + <div + class="gl-text-right gl-h-5" + > + <!----> + </div> + + <div + class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" + > + <img + src="http://example.com/images/illustration.svg" + /> + + <h6> + Set up CI/CD + </h6> + + <p + class="gl-font-sm gl-text-gray-700" + > + Save time by automating your integration and deployment tasks. + </p> + + <a + class="gl-link" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Set-up CI/CD + </a> + </div> + </div> + + <!----> + </div> + </div> + + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0" + > + <!----> + + <div + class="gl-card-body" + > + <div + class="gl-text-right gl-h-5" + > + <!----> + </div> + + <div + class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" + > + <img + src="http://example.com/images/illustration.svg" + /> + + <h6> + Start a free Ultimate trial + </h6> + + <p + class="gl-font-sm gl-text-gray-700" + > + Try all GitLab features for 30 days, no credit card required. + </p> + + <a + class="gl-link" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Try GitLab Ultimate for free + </a> + </div> + </div> + + <!----> + </div> + </div> + + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0" + > + <!----> + + <div + class="gl-card-body" + > + <div + class="gl-text-right gl-h-5" + > + <span + class="gl-text-gray-500 gl-font-sm gl-font-style-italic" + data-testid="trial-only" + > + Trial only + </span> + </div> + + <div + class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" + > + <img + src="http://example.com/images/illustration.svg" + /> + + <h6> + Add code owners + </h6> + + <p + class="gl-font-sm gl-text-gray-700" + > + Prevent unexpected changes to important assets by assigning ownership of files and paths. + </p> + + <a + class="gl-link" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Add code owners + </a> + </div> + </div> + + <!----> + </div> + </div> + + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0" + > + <!----> + + <div + class="gl-card-body" + > + <div + class="gl-text-right gl-h-5" + > + <span + class="gl-text-gray-500 gl-font-sm gl-font-style-italic" + data-testid="trial-only" + > + Trial only + </span> + </div> + + <div + class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" + > + <img + src="http://example.com/images/illustration.svg" + /> + + <h6> + Add merge request approval + </h6> + + <p + class="gl-font-sm gl-text-gray-700" + > + Route code reviews to the right reviewers, every time. + </p> + + <a + class="gl-link" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Enable require merge approvals + </a> + </div> + </div> + + <!----> + </div> + </div> + </div> + + <h2 + class="gl-font-lg gl-mb-3" + > + Plan and execute + </h2> + + <p + class="gl-text-gray-700 gl-mb-6" + > + Create a workflow for your new workspace, and learn how GitLab features work together: + </p> + + <div + class="row row-cols-2 row-cols-md-3 row-cols-lg-4" + > + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0" + > + <!----> + + <div + class="gl-card-body" + > + <div + class="gl-text-right gl-h-5" + > + <!----> + </div> + + <div + class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" + > + <img + src="http://example.com/images/illustration.svg" + /> + + <h6> + Submit a merge request + </h6> + + <p + class="gl-font-sm gl-text-gray-700" + > + Review and edit proposed changes to source code. + </p> + + <a + class="gl-link" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Submit a merge request (MR) + </a> + </div> + </div> + + <!----> + </div> + </div> + </div> + + <h2 + class="gl-font-lg gl-mb-3" + > + Deploy + </h2> + + <p + class="gl-text-gray-700 gl-mb-6" + > + Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure: + </p> + + <div + class="row row-cols-2 row-cols-lg-4 g-2 g-lg-3" + > + <div + class="col gl-mb-6" + > + <div + class="gl-card gl-pt-0" + > + <!----> + + <div + class="gl-card-body" + > + <div + class="gl-text-right gl-h-5" + > + <!----> + </div> + + <div + class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" + > + <img + src="http://example.com/images/illustration.svg" + /> + + <h6> + Run a security scan + </h6> + + <p + class="gl-font-sm gl-text-gray-700" + > + Scan your code to uncover vulnerabilities before deploying. + </p> + + <a + class="gl-link" + href="http://example.com/" + rel="noopener noreferrer" + target="_blank" + > + Run a Security scan + </a> + </div> + </div> + + <!----> + </div> + </div> + </div> +</div> `; diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js index ddc5339e7e0..2154358de51 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js @@ -1,41 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import LearnGitlabA from '~/pages/projects/learn_gitlab/components/learn_gitlab_a.vue'; - -const TEST_ACTIONS = { - gitWrite: { - url: 'http://example.com/', - completed: true, - }, - userAdded: { - url: 'http://example.com/', - completed: true, - }, - pipelineCreated: { - url: 'http://example.com/', - completed: true, - }, - trialStarted: { - url: 'http://example.com/', - completed: false, - }, - codeOwnersEnabled: { - url: 'http://example.com/', - completed: false, - }, - requiredMrApprovalsEnabled: { - url: 'http://example.com/', - completed: false, - }, - mergeRequestCreated: { - url: 'http://example.com/', - completed: false, - }, - securityScanEnabled: { - url: 'http://example.com/', - completed: false, - }, -}; +import { testActions } from './mock_data'; describe('Learn GitLab Design A', () => { let wrapper; @@ -46,13 +11,7 @@ describe('Learn GitLab Design A', () => { }); const createWrapper = () => { - wrapper = extendedWrapper( - shallowMount(LearnGitlabA, { - propsData: { - actions: TEST_ACTIONS, - }, - }), - ); + wrapper = shallowMount(LearnGitlabA, { propsData: { actions: testActions } }); }; it('should render the loading state', () => { diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js index be4f5768402..fbb989fae32 100644 --- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js @@ -1,63 +1,38 @@ -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import LearnGitlabA from '~/pages/projects/learn_gitlab/components/learn_gitlab_a.vue'; - -const TEST_ACTIONS = { - gitWrite: { - url: 'http://example.com/', - completed: true, - }, - userAdded: { - url: 'http://example.com/', - completed: true, - }, - pipelineCreated: { - url: 'http://example.com/', - completed: true, - }, - trialStarted: { - url: 'http://example.com/', - completed: false, - }, - codeOwnersEnabled: { - url: 'http://example.com/', - completed: false, - }, - requiredMrApprovalsEnabled: { - url: 'http://example.com/', - completed: false, - }, - mergeRequestCreated: { - url: 'http://example.com/', - completed: false, - }, - securityScanEnabled: { - url: 'http://example.com/', - completed: false, - }, -}; +import { GlProgressBar } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import LearnGitlabB from '~/pages/projects/learn_gitlab/components/learn_gitlab_b.vue'; +import { testActions } from './mock_data'; describe('Learn GitLab Design B', () => { let wrapper; + const createWrapper = () => { + wrapper = mount(LearnGitlabB, { propsData: { actions: testActions } }); + }; + + beforeEach(() => { + createWrapper(); + }); + afterEach(() => { wrapper.destroy(); wrapper = null; }); - const createWrapper = () => { - wrapper = extendedWrapper( - shallowMount(LearnGitlabA, { - propsData: { - actions: TEST_ACTIONS, - }, - }), - ); - }; + it('renders correctly', () => { + expect(wrapper.element).toMatchSnapshot(); + }); - it('should render the loading state', () => { - createWrapper(); + it('renders the progress percentage', () => { + const text = wrapper.find('[data-testid="completion-percentage"]').text(); - expect(wrapper.element).toMatchSnapshot(); + expect(text).toEqual('25% completed'); + }); + + it('renders the progress bar with correct values', () => { + const progressBar = wrapper.find(GlProgressBar); + + expect(progressBar.attributes('value')).toBe('2'); + expect(progressBar.attributes('max')).toBe('8'); }); }); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_info_card_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_info_card_spec.js new file mode 100644 index 00000000000..ad4bc826a9d --- /dev/null +++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_info_card_spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; +import LearnGitlabInfoCard from '~/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue'; + +const defaultProps = { + title: 'Create Repository', + description: 'Some description', + actionLabel: 'Create Repository now', + url: 'https://example.com', + completed: false, + svg: 'https://example.com/illustration.svg', +}; + +describe('Learn GitLab Info Card', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const createWrapper = (props = {}) => { + wrapper = shallowMount(LearnGitlabInfoCard, { + propsData: { ...defaultProps, ...props }, + }); + }; + + it('renders no icon when not completed', () => { + createWrapper({ completed: false }); + + expect(wrapper.find('[data-testid="completed-icon"]').exists()).toBe(false); + }); + + it('renders the completion icon when completed', () => { + createWrapper({ completed: true }); + + expect(wrapper.find('[data-testid="completed-icon"]').exists()).toBe(true); + }); + + it('renders no trial only when it is not required', () => { + createWrapper(); + + expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(false); + }); + + it('renders trial only when trial is required', () => { + createWrapper({ trialRequired: true }); + + expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(true); + }); + + it('renders completion icon when completed a trial-only feature', () => { + createWrapper({ trialRequired: true, completed: true }); + + expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="completed-icon"]').exists()).toBe(true); + }); +}); diff --git a/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js new file mode 100644 index 00000000000..caac667e2b1 --- /dev/null +++ b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js @@ -0,0 +1,42 @@ +export const testActions = { + gitWrite: { + url: 'http://example.com/', + completed: true, + svg: 'http://example.com/images/illustration.svg', + }, + userAdded: { + url: 'http://example.com/', + completed: true, + svg: 'http://example.com/images/illustration.svg', + }, + pipelineCreated: { + url: 'http://example.com/', + completed: false, + svg: 'http://example.com/images/illustration.svg', + }, + trialStarted: { + url: 'http://example.com/', + completed: false, + svg: 'http://example.com/images/illustration.svg', + }, + codeOwnersEnabled: { + url: 'http://example.com/', + completed: false, + svg: 'http://example.com/images/illustration.svg', + }, + requiredMrApprovalsEnabled: { + url: 'http://example.com/', + completed: false, + svg: 'http://example.com/images/illustration.svg', + }, + mergeRequestCreated: { + url: 'http://example.com/', + completed: false, + svg: 'http://example.com/images/illustration.svg', + }, + securityScanEnabled: { + url: 'http://example.com/', + completed: false, + svg: 'http://example.com/images/illustration.svg', + }, +}; diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js index de63409b181..2a3b07f95f2 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js @@ -6,8 +6,6 @@ import TimezoneDropdown, { } from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown'; describe('Timezone Dropdown', () => { - preloadFixtures('pipeline_schedules/edit.html'); - let $inputEl = null; let $dropdownEl = null; let $wrapper = null; diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js index d7c754fd3cc..bee628c3a56 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js +++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js @@ -29,6 +29,7 @@ const defaultProps = { showDefaultAwardEmojis: true, allowEditingCommitMessages: false, }, + isGitlabCom: true, canDisableEmails: true, canChangeVisibilityLevel: true, allowedVisibilityOptions: [0, 10, 20], diff --git a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js index 8632c852720..e39a3904613 100644 --- a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js +++ b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js @@ -6,8 +6,6 @@ describe('preserve_url_fragment', () => { return $(`.omniauth-container ${selector}`).parent('form').attr('action'); }; - preloadFixtures('sessions/new.html'); - beforeEach(() => { loadFixtures('sessions/new.html'); }); diff --git a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js index f04c16d2ddb..6aa725fbd7d 100644 --- a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js +++ b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js @@ -18,8 +18,6 @@ describe('SigninTabsMemoizer', () => { return memo; } - preloadFixtures(fixtureTemplate); - beforeEach(() => { loadFixtures(fixtureTemplate); diff --git a/spec/frontend/pages/shared/wikis/wiki_alert_spec.js b/spec/frontend/pages/shared/wikis/wiki_alert_spec.js new file mode 100644 index 00000000000..6a18473b1a7 --- /dev/null +++ b/spec/frontend/pages/shared/wikis/wiki_alert_spec.js @@ -0,0 +1,40 @@ +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import WikiAlert from '~/pages/shared/wikis/components/wiki_alert.vue'; + +describe('WikiAlert', () => { + let wrapper; + const ERROR = 'There is already a page with the same title in that path.'; + const ERROR_WITH_LINK = 'Before text %{wikiLinkStart}the page%{wikiLinkEnd} after text.'; + const PATH = '/test'; + + function createWrapper(propsData = {}, stubs = {}) { + wrapper = shallowMount(WikiAlert, { + propsData: { wikiPagePath: PATH, ...propsData }, + stubs, + }); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findGlAlert = () => wrapper.findComponent(GlAlert); + const findGlLink = () => wrapper.findComponent(GlLink); + const findGlSprintf = () => wrapper.findComponent(GlSprintf); + + describe('Wiki Alert', () => { + it('shows an alert when there is an error', () => { + createWrapper({ error: ERROR }); + expect(findGlAlert().exists()).toBe(true); + expect(findGlSprintf().exists()).toBe(true); + expect(findGlSprintf().attributes('message')).toBe(ERROR); + }); + + it('shows a the link to the help path', () => { + createWrapper({ error: ERROR_WITH_LINK }, { GlAlert, GlSprintf }); + expect(findGlLink().attributes('href')).toBe(PATH); + }); + }); +}); diff --git a/spec/frontend/performance_bar/components/performance_bar_app_spec.js b/spec/frontend/performance_bar/components/performance_bar_app_spec.js index 417a655093c..67a4259a8e3 100644 --- a/spec/frontend/performance_bar/components/performance_bar_app_spec.js +++ b/spec/frontend/performance_bar/components/performance_bar_app_spec.js @@ -9,6 +9,7 @@ describe('performance bar app', () => { store, env: 'development', requestId: '123', + statsUrl: 'https://log.gprd.gitlab.net/app/dashboards#/view/', peekUrl: '/-/peek/results', profileUrl: '?lineprofiler=true', }, diff --git a/spec/frontend/performance_bar/index_spec.js b/spec/frontend/performance_bar/index_spec.js index 8d9c32b7f12..819b2bcbacf 100644 --- a/spec/frontend/performance_bar/index_spec.js +++ b/spec/frontend/performance_bar/index_spec.js @@ -19,6 +19,7 @@ describe('performance bar wrapper', () => { peekWrapper.setAttribute('data-env', 'development'); peekWrapper.setAttribute('data-request-id', '123'); peekWrapper.setAttribute('data-peek-url', '/-/peek/results'); + peekWrapper.setAttribute('data-stats-url', 'https://log.gprd.gitlab.net/app/dashboards#/view/'); peekWrapper.setAttribute('data-profile-url', '?lineprofiler=true'); mock = new MockAdapter(axios); diff --git a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js index 5dae77a4626..8040c9d701c 100644 --- a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js +++ b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js @@ -12,7 +12,7 @@ describe('Pipeline Editor | Commit Form', () => { wrapper = mountFn(CommitForm, { propsData: { defaultMessage: mockCommitMessage, - defaultBranch: mockDefaultBranch, + currentBranch: mockDefaultBranch, ...props, }, @@ -41,7 +41,7 @@ describe('Pipeline Editor | Commit Form', () => { expect(findCommitTextarea().attributes('value')).toBe(mockCommitMessage); }); - it('shows a default branch', () => { + it('shows current branch', () => { expect(findBranchInput().attributes('value')).toBe(mockDefaultBranch); }); @@ -66,7 +66,7 @@ describe('Pipeline Editor | Commit Form', () => { expect(wrapper.emitted('submit')[0]).toEqual([ { message: mockCommitMessage, - branch: mockDefaultBranch, + targetBranch: mockDefaultBranch, openMergeRequest: false, }, ]); @@ -101,7 +101,7 @@ describe('Pipeline Editor | Commit Form', () => { expect(wrapper.emitted('submit')[0]).toEqual([ { message: anotherMessage, - branch: anotherBranch, + targetBranch: anotherBranch, openMergeRequest: true, }, ]); diff --git a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js index b87ff6ec0de..9e677425807 100644 --- a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js +++ b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js @@ -3,7 +3,11 @@ import { mount } from '@vue/test-utils'; import { objectToQuery, redirectTo } from '~/lib/utils/url_utility'; import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue'; -import { COMMIT_SUCCESS } from '~/pipeline_editor/constants'; +import { + COMMIT_ACTION_CREATE, + COMMIT_ACTION_UPDATE, + COMMIT_SUCCESS, +} from '~/pipeline_editor/constants'; import commitCreate from '~/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql'; import { @@ -25,6 +29,7 @@ jest.mock('~/lib/utils/url_utility', () => ({ })); const mockVariables = { + action: COMMIT_ACTION_UPDATE, projectPath: mockProjectFullPath, startBranch: mockDefaultBranch, message: mockCommitMessage, @@ -35,7 +40,6 @@ const mockVariables = { const mockProvide = { ciConfigPath: mockCiConfigPath, - defaultBranch: mockDefaultBranch, projectFullPath: mockProjectFullPath, newMergeRequestPath: mockNewMergeRequestPath, }; @@ -64,6 +68,8 @@ describe('Pipeline Editor | Commit section', () => { data() { return { commitSha: mockCommitSha, + currentBranch: mockDefaultBranch, + isNewCiConfigFile: Boolean(options?.isNewCiConfigfile), }; }, mocks: { @@ -100,23 +106,58 @@ describe('Pipeline Editor | Commit section', () => { await findCancelBtn().trigger('click'); }; - beforeEach(() => { - createComponent(); - }); - afterEach(() => { mockMutate.mockReset(); - wrapper.destroy(); - wrapper = null; + }); + + describe('when the user commits a new file', () => { + beforeEach(async () => { + createComponent({ options: { isNewCiConfigfile: true } }); + await submitCommit(); + }); + + it('calls the mutation with the CREATE action', () => { + expect(mockMutate).toHaveBeenCalledTimes(1); + expect(mockMutate).toHaveBeenCalledWith({ + mutation: commitCreate, + update: expect.any(Function), + variables: { + ...mockVariables, + action: COMMIT_ACTION_CREATE, + branch: mockDefaultBranch, + }, + }); + }); + }); + + describe('when the user commits an update to an existing file', () => { + beforeEach(async () => { + createComponent(); + await submitCommit(); + }); + + it('calls the mutation with the UPDATE action', () => { + expect(mockMutate).toHaveBeenCalledTimes(1); + expect(mockMutate).toHaveBeenCalledWith({ + mutation: commitCreate, + update: expect.any(Function), + variables: { + ...mockVariables, + action: COMMIT_ACTION_UPDATE, + branch: mockDefaultBranch, + }, + }); + }); }); describe('when the user commits changes to the current branch', () => { beforeEach(async () => { + createComponent(); await submitCommit(); }); - it('calls the mutation with the default branch', () => { + it('calls the mutation with the current branch', () => { expect(mockMutate).toHaveBeenCalledTimes(1); expect(mockMutate).toHaveBeenCalledWith({ mutation: commitCreate, @@ -157,6 +198,7 @@ describe('Pipeline Editor | Commit section', () => { const newBranch = 'new-branch'; beforeEach(async () => { + createComponent(); await submitCommit({ branch: newBranch, }); @@ -178,6 +220,7 @@ describe('Pipeline Editor | Commit section', () => { const newBranch = 'new-branch'; beforeEach(async () => { + createComponent(); await submitCommit({ branch: newBranch, openMergeRequest: true, @@ -195,6 +238,10 @@ describe('Pipeline Editor | Commit section', () => { }); describe('when the commit is ocurring', () => { + beforeEach(() => { + createComponent(); + }); + it('shows a saving state', async () => { mockMutate.mockImplementationOnce(() => { expect(findCommitBtnLoadingIcon().exists()).toBe(true); diff --git a/spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js b/spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js index df15a6c8e7f..ef8ca574e59 100644 --- a/spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js +++ b/spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js @@ -1,21 +1,33 @@ import { shallowMount } from '@vue/test-utils'; import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue'; +import PipelineStatus from '~/pipeline_editor/components/header/pipeline_status.vue'; import ValidationSegment from '~/pipeline_editor/components/header/validation_segment.vue'; -import { mockLintResponse } from '../../mock_data'; +import { mockCiYml, mockLintResponse } from '../../mock_data'; describe('Pipeline editor header', () => { let wrapper; + const mockProvide = { + glFeatures: { + pipelineStatusForPipelineEditor: true, + }, + }; - const createComponent = () => { + const createComponent = ({ provide = {} } = {}) => { wrapper = shallowMount(PipelineEditorHeader, { - props: { + provide: { + ...mockProvide, + ...provide, + }, + propsData: { ciConfigData: mockLintResponse, + ciFileContent: mockCiYml, isCiConfigDataLoading: false, }, }); }; + const findPipelineStatus = () => wrapper.findComponent(PipelineStatus); const findValidationSegment = () => wrapper.findComponent(ValidationSegment); afterEach(() => { @@ -27,8 +39,27 @@ describe('Pipeline editor header', () => { beforeEach(() => { createComponent(); }); + + it('renders the pipeline status', () => { + expect(findPipelineStatus().exists()).toBe(true); + }); + it('renders the validation segment', () => { expect(findValidationSegment().exists()).toBe(true); }); }); + + describe('with pipeline status feature flag off', () => { + beforeEach(() => { + createComponent({ + provide: { + glFeatures: { pipelineStatusForPipelineEditor: false }, + }, + }); + }); + + it('does not render the pipeline status', () => { + expect(findPipelineStatus().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js new file mode 100644 index 00000000000..de6e112866b --- /dev/null +++ b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js @@ -0,0 +1,150 @@ +import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import PipelineStatus, { i18n } from '~/pipeline_editor/components/header/pipeline_status.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { mockCommitSha, mockProjectPipeline, mockProjectFullPath } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +const mockProvide = { + projectFullPath: mockProjectFullPath, +}; + +describe('Pipeline Status', () => { + let wrapper; + let mockApollo; + let mockPipelineQuery; + + const createComponent = ({ hasPipeline = true, isQueryLoading = false }) => { + const pipeline = hasPipeline + ? { loading: isQueryLoading, ...mockProjectPipeline.pipeline } + : { loading: isQueryLoading }; + + wrapper = shallowMount(PipelineStatus, { + provide: mockProvide, + stubs: { GlLink, GlSprintf }, + data: () => (hasPipeline ? { pipeline } : {}), + mocks: { + $apollo: { + queries: { + pipeline, + }, + }, + }, + }); + }; + + const createComponentWithApollo = () => { + const resolvers = { + Query: { + project: mockPipelineQuery, + }, + }; + mockApollo = createMockApollo([], resolvers); + + wrapper = shallowMount(PipelineStatus, { + localVue, + apolloProvider: mockApollo, + provide: mockProvide, + stubs: { GlLink, GlSprintf }, + data() { + return { + commitSha: mockCommitSha, + }; + }, + }); + }; + + const findIcon = () => wrapper.findComponent(GlIcon); + const findCiIcon = () => wrapper.findComponent(CiIcon); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findPipelineId = () => wrapper.find('[data-testid="pipeline-id"]'); + const findPipelineCommit = () => wrapper.find('[data-testid="pipeline-commit"]'); + const findPipelineErrorMsg = () => wrapper.find('[data-testid="pipeline-error-msg"]'); + const findPipelineLoadingMsg = () => wrapper.find('[data-testid="pipeline-loading-msg"]'); + + beforeEach(() => { + mockPipelineQuery = jest.fn(); + }); + + afterEach(() => { + mockPipelineQuery.mockReset(); + + wrapper.destroy(); + wrapper = null; + }); + + describe('while querying', () => { + it('renders loading icon', () => { + createComponent({ isQueryLoading: true, hasPipeline: false }); + + expect(findLoadingIcon().exists()).toBe(true); + expect(findPipelineLoadingMsg().text()).toBe(i18n.fetchLoading); + }); + + it('does not render loading icon if pipeline data is already set', () => { + createComponent({ isQueryLoading: true }); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('when querying data', () => { + describe('when data is set', () => { + beforeEach(async () => { + mockPipelineQuery.mockResolvedValue(mockProjectPipeline); + + createComponentWithApollo(); + await waitForPromises(); + }); + + it('query is called with correct variables', async () => { + expect(mockPipelineQuery).toHaveBeenCalledTimes(1); + expect(mockPipelineQuery).toHaveBeenCalledWith( + expect.anything(), + { + fullPath: mockProjectFullPath, + }, + expect.anything(), + expect.anything(), + ); + }); + + it('does not render error', () => { + expect(findIcon().exists()).toBe(false); + }); + + it('renders pipeline data', () => { + const { id } = mockProjectPipeline.pipeline; + + expect(findCiIcon().exists()).toBe(true); + expect(findPipelineId().text()).toBe(`#${id.match(/\d+/g)[0]}`); + expect(findPipelineCommit().text()).toBe(mockCommitSha); + }); + }); + + describe('when data cannot be fetched', () => { + beforeEach(async () => { + mockPipelineQuery.mockRejectedValue(new Error()); + + createComponentWithApollo(); + await waitForPromises(); + }); + + it('renders error', () => { + expect(findIcon().attributes('name')).toBe('warning-solid'); + expect(findPipelineErrorMsg().text()).toBe(i18n.fetchError); + }); + + it('does not render pipeline data', () => { + expect(findCiIcon().exists()).toBe(false); + expect(findPipelineId().exists()).toBe(false); + expect(findPipelineCommit().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js b/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js index cf1d89e1d7c..274c2d1b8da 100644 --- a/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js +++ b/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js @@ -7,9 +7,9 @@ import ValidationSegment, { i18n, } from '~/pipeline_editor/components/header/validation_segment.vue'; import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; -import { mockYmlHelpPagePath, mergeUnwrappedCiConfig } from '../../mock_data'; +import { mockYmlHelpPagePath, mergeUnwrappedCiConfig, mockCiYml } from '../../mock_data'; -describe('~/pipeline_editor/components/info/validation_segment.vue', () => { +describe('Validation segment component', () => { let wrapper; const createComponent = (props = {}) => { @@ -20,6 +20,7 @@ describe('~/pipeline_editor/components/info/validation_segment.vue', () => { }, propsData: { ciConfig: mergeUnwrappedCiConfig(), + ciFileContent: mockCiYml, loading: false, ...props, }, @@ -42,6 +43,20 @@ describe('~/pipeline_editor/components/info/validation_segment.vue', () => { expect(wrapper.text()).toBe(i18n.loading); }); + describe('when config is empty', () => { + beforeEach(() => { + createComponent({ ciFileContent: '' }); + }); + + it('has check icon', () => { + expect(findIcon().props('name')).toBe('check'); + }); + + it('shows a message for empty state', () => { + expect(findValidationMsg().text()).toBe(i18n.empty); + }); + }); + describe('when config is valid', () => { beforeEach(() => { createComponent({}); @@ -61,7 +76,7 @@ describe('~/pipeline_editor/components/info/validation_segment.vue', () => { }); }); - describe('when config is not valid', () => { + describe('when config is invalid', () => { beforeEach(() => { createComponent({ ciConfig: mergeUnwrappedCiConfig({ diff --git a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js new file mode 100644 index 00000000000..b444d9dcfea --- /dev/null +++ b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js @@ -0,0 +1,79 @@ +import { GlButton, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; + +describe('Pipeline editor empty state', () => { + let wrapper; + const defaultProvide = { + glFeatures: { + pipelineEditorEmptyStateAction: false, + }, + emptyStateIllustrationPath: 'my/svg/path', + }; + + const createComponent = ({ provide } = {}) => { + wrapper = shallowMount(PipelineEditorEmptyState, { + provide: { ...defaultProvide, ...provide }, + }); + }; + + const findSvgImage = () => wrapper.find('img'); + const findTitle = () => wrapper.find('h1'); + const findConfirmButton = () => wrapper.findComponent(GlButton); + const findDescription = () => wrapper.findComponent(GlSprintf); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders an svg image', () => { + expect(findSvgImage().exists()).toBe(true); + }); + + it('renders a title', () => { + expect(findTitle().exists()).toBe(true); + expect(findTitle().text()).toBe(wrapper.vm.$options.i18n.title); + }); + + it('renders a description', () => { + expect(findDescription().exists()).toBe(true); + expect(findDescription().html()).toContain(wrapper.vm.$options.i18n.body); + }); + + describe('with feature flag off', () => { + it('does not renders a CTA button', () => { + expect(findConfirmButton().exists()).toBe(false); + }); + }); + }); + + describe('with feature flag on', () => { + beforeEach(() => { + createComponent({ + provide: { + glFeatures: { + pipelineEditorEmptyStateAction: true, + }, + }, + }); + }); + + it('renders a CTA button', () => { + expect(findConfirmButton().exists()).toBe(true); + expect(findConfirmButton().text()).toBe(wrapper.vm.$options.i18n.btnText); + }); + + it('emits an event when clicking on the CTA', async () => { + const expectedEvent = 'createEmptyConfigFile'; + expect(wrapper.emitted(expectedEvent)).toBeUndefined(); + + await findConfirmButton().vm.$emit('click'); + expect(wrapper.emitted(expectedEvent)).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js index d39c0d80296..196a4133eea 100644 --- a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js +++ b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js @@ -46,6 +46,24 @@ describe('~/pipeline_editor/graphql/resolvers', () => { await expect(result.rawData).resolves.toBe(mockCiYml); }); }); + + describe('pipeline', () => { + it('resolves pipeline data with type names', async () => { + const result = await resolvers.Query.project(null); + + // eslint-disable-next-line no-underscore-dangle + expect(result.__typename).toBe('Project'); + }); + + it('resolves pipeline data with necessary data', async () => { + const result = await resolvers.Query.project(null); + const pipelineKeys = Object.keys(result.pipeline); + const statusKeys = Object.keys(result.pipeline.detailedStatus); + + expect(pipelineKeys).toContain('id', 'commitPath', 'detailedStatus', 'shortSha'); + expect(statusKeys).toContain('detailsPath', 'text'); + }); + }); }); describe('Mutation', () => { diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index 8e248c11b87..16d5ba0e714 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -138,6 +138,22 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => { }; }; +export const mockProjectPipeline = { + pipeline: { + commitPath: '/-/commit/aabbccdd', + id: 'gid://gitlab/Ci::Pipeline/118', + iid: '28', + shortSha: mockCommitSha, + status: 'SUCCESS', + detailedStatus: { + detailsPath: '/root/sample-ci-project/-/pipelines/118"', + group: 'success', + icon: 'status_success', + text: 'passed', + }, + }, +}; + export const mockLintResponse = { valid: true, mergedYaml: mockCiYml, diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index 46d0452f437..887d296222f 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -7,6 +7,8 @@ import httpStatusCodes from '~/lib/utils/http_status'; import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue'; +import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; +import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; import { COMMIT_SUCCESS, COMMIT_FAILURE, LOAD_FAILURE_UNKNOWN } from '~/pipeline_editor/constants'; import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.graphql'; import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue'; @@ -29,6 +31,9 @@ const MockEditorLite = { const mockProvide = { ciConfigPath: mockCiConfigPath, defaultBranch: mockDefaultBranch, + glFeatures: { + pipelineEditorEmptyStateAction: false, + }, projectFullPath: mockProjectFullPath, }; @@ -39,14 +44,17 @@ describe('Pipeline editor app component', () => { let mockBlobContentData; let mockCiConfigData; - const createComponent = ({ blobLoading = false, options = {} } = {}) => { + const createComponent = ({ blobLoading = false, options = {}, provide = {} } = {}) => { wrapper = shallowMount(PipelineEditorApp, { - provide: mockProvide, + provide: { ...mockProvide, ...provide }, stubs: { GlTabs, GlButton, CommitForm, + PipelineEditorHome, + PipelineEditorTabs, EditorLite: MockEditorLite, + PipelineEditorEmptyState, }, mocks: { $apollo: { @@ -64,7 +72,7 @@ describe('Pipeline editor app component', () => { }); }; - const createComponentWithApollo = ({ props = {} } = {}) => { + const createComponentWithApollo = ({ props = {}, provide = {} } = {}) => { const handlers = [[getCiConfigData, mockCiConfigData]]; const resolvers = { Query: { @@ -85,13 +93,16 @@ describe('Pipeline editor app component', () => { apolloProvider: mockApollo, }; - createComponent({ props, options }); + createComponent({ props, provide, options }); }; const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAlert = () => wrapper.findComponent(GlAlert); const findEditorHome = () => wrapper.findComponent(PipelineEditorHome); const findTextEditor = () => wrapper.findComponent(TextEditor); + const findEmptyState = () => wrapper.findComponent(PipelineEditorEmptyState); + const findEmptyStateButton = () => + wrapper.findComponent(PipelineEditorEmptyState).findComponent(GlButton); beforeEach(() => { mockBlobContentData = jest.fn(); @@ -103,7 +114,6 @@ describe('Pipeline editor app component', () => { mockCiConfigData.mockReset(); wrapper.destroy(); - wrapper = null; }); it('displays a loading icon if the blob query is loading', () => { @@ -146,45 +156,79 @@ describe('Pipeline editor app component', () => { }); }); - describe('when no file exists', () => { - const noFileAlertMsg = - 'There is no .gitlab-ci.yml file in this repository, please add one and visit the Pipeline Editor again.'; + describe('when no CI config file exists', () => { + describe('in a project without a repository', () => { + it('shows an empty state and does not show editor home component', async () => { + mockBlobContentData.mockRejectedValueOnce({ + response: { + status: httpStatusCodes.BAD_REQUEST, + }, + }); + createComponentWithApollo(); - it('shows a 404 error message and does not show editor home component', async () => { - mockBlobContentData.mockRejectedValueOnce({ - response: { - status: httpStatusCodes.NOT_FOUND, - }, + await waitForPromises(); + + expect(findEmptyState().exists()).toBe(true); + expect(findAlert().exists()).toBe(false); + expect(findEditorHome().exists()).toBe(false); }); - createComponentWithApollo(); + }); - await waitForPromises(); + describe('in a project with a repository', () => { + it('shows an empty state and does not show editor home component', async () => { + mockBlobContentData.mockRejectedValueOnce({ + response: { + status: httpStatusCodes.NOT_FOUND, + }, + }); + createComponentWithApollo(); - expect(findAlert().text()).toBe(noFileAlertMsg); - expect(findEditorHome().exists()).toBe(false); + await waitForPromises(); + + expect(findEmptyState().exists()).toBe(true); + expect(findAlert().exists()).toBe(false); + expect(findEditorHome().exists()).toBe(false); + }); + }); + + describe('because of a fetching error', () => { + it('shows a unkown error message', async () => { + mockBlobContentData.mockRejectedValueOnce(new Error('My error!')); + createComponentWithApollo(); + await waitForPromises(); + + expect(findEmptyState().exists()).toBe(false); + expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[LOAD_FAILURE_UNKNOWN]); + expect(findEditorHome().exists()).toBe(true); + }); }); + }); - it('shows a 400 error message and does not show editor home component', async () => { + describe('when landing on the empty state with feature flag on', () => { + it('user can click on CTA button and see an empty editor', async () => { mockBlobContentData.mockRejectedValueOnce({ response: { - status: httpStatusCodes.BAD_REQUEST, + status: httpStatusCodes.NOT_FOUND, + }, + }); + + createComponentWithApollo({ + provide: { + glFeatures: { + pipelineEditorEmptyStateAction: true, + }, }, }); - createComponentWithApollo(); await waitForPromises(); - expect(findAlert().text()).toBe(noFileAlertMsg); - expect(findEditorHome().exists()).toBe(false); - }); + expect(findEmptyState().exists()).toBe(true); + expect(findTextEditor().exists()).toBe(false); - it('shows a unkown error message', async () => { - mockBlobContentData.mockRejectedValueOnce(new Error('My error!')); - createComponentWithApollo(); - await waitForPromises(); + await findEmptyStateButton().vm.$emit('click'); - expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[LOAD_FAILURE_UNKNOWN]); - expect(findEditorHome().exists()).toBe(true); + expect(findEmptyState().exists()).toBe(false); + expect(findTextEditor().exists()).toBe(true); }); }); @@ -193,6 +237,7 @@ describe('Pipeline editor app component', () => { describe('and the commit mutation succeeds', () => { beforeEach(() => { + window.scrollTo = jest.fn(); createComponent(); findEditorHome().vm.$emit('commit', { type: COMMIT_SUCCESS }); @@ -201,11 +246,16 @@ describe('Pipeline editor app component', () => { it('shows a confirmation message', () => { expect(findAlert().text()).toBe(wrapper.vm.$options.successTexts[COMMIT_SUCCESS]); }); + + it('scrolls to the top of the page to bring attention to the confirmation message', () => { + expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); + }); }); describe('and the commit mutation fails', () => { const commitFailedReasons = ['Commit failed']; beforeEach(() => { + window.scrollTo = jest.fn(); createComponent(); findEditorHome().vm.$emit('showError', { @@ -219,11 +269,17 @@ describe('Pipeline editor app component', () => { `${updateFailureMessage} ${commitFailedReasons[0]}`, ); }); + + it('scrolls to the top of the page to bring attention to the error message', () => { + expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); + }); }); + describe('when an unknown error occurs', () => { const unknownReasons = ['Commit failed']; beforeEach(() => { + window.scrollTo = jest.fn(); createComponent(); findEditorHome().vm.$emit('showError', { @@ -237,6 +293,10 @@ describe('Pipeline editor app component', () => { `${updateFailureMessage} ${unknownReasons[0]}`, ); }); + + it('scrolls to the top of the page to bring attention to the error message', () => { + expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); + }); }); }); }); diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js index 51bb0ecee9c..7ec5818010a 100644 --- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem, GlForm, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; +import { GlForm, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; @@ -6,34 +6,26 @@ import axios from '~/lib/utils/axios_utils'; import httpStatusCodes from '~/lib/utils/http_status'; import { redirectTo } from '~/lib/utils/url_utility'; import PipelineNewForm from '~/pipeline_new/components/pipeline_new_form.vue'; -import { - mockBranches, - mockTags, - mockParams, - mockPostParams, - mockProjectId, - mockError, -} from '../mock_data'; +import RefsDropdown from '~/pipeline_new/components/refs_dropdown.vue'; +import { mockQueryParams, mockPostParams, mockProjectId, mockError, mockRefs } from '../mock_data'; jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), })); +const projectRefsEndpoint = '/root/project/refs'; const pipelinesPath = '/root/project/-/pipelines'; const configVariablesPath = '/root/project/-/pipelines/config_variables'; -const postResponse = { id: 1 }; +const newPipelinePostResponse = { id: 1 }; +const defaultBranch = 'master'; describe('Pipeline New Form', () => { let wrapper; let mock; - - const dummySubmitEvent = { - preventDefault() {}, - }; + let dummySubmitEvent; const findForm = () => wrapper.find(GlForm); - const findDropdown = () => wrapper.find(GlDropdown); - const findDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findRefsDropdown = () => wrapper.findComponent(RefsDropdown); const findSubmitButton = () => wrapper.find('[data-testid="run_pipeline_button"]'); const findVariableRows = () => wrapper.findAll('[data-testid="ci-variable-row"]'); const findRemoveIcons = () => wrapper.findAll('[data-testid="remove-ci-variable-row"]'); @@ -44,33 +36,42 @@ describe('Pipeline New Form', () => { const findWarningAlertSummary = () => findWarningAlert().find(GlSprintf); const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]'); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - const getExpectedPostParams = () => JSON.parse(mock.history.post[0].data); - const changeRef = (i) => findDropdownItems().at(i).vm.$emit('click'); + const getFormPostParams = () => JSON.parse(mock.history.post[0].data); + + const selectBranch = (branch) => { + // Select a branch in the dropdown + findRefsDropdown().vm.$emit('input', { + shortName: branch, + fullName: `refs/heads/${branch}`, + }); + }; - const createComponent = (term = '', props = {}, method = shallowMount) => { + const createComponent = (props = {}, method = shallowMount) => { wrapper = method(PipelineNewForm, { + provide: { + projectRefsEndpoint, + }, propsData: { projectId: mockProjectId, pipelinesPath, configVariablesPath, - branches: mockBranches, - tags: mockTags, - defaultBranch: 'master', + defaultBranch, + refParam: defaultBranch, settingsLink: '', maxWarnings: 25, ...props, }, - data() { - return { - searchTerm: term, - }; - }, }); }; beforeEach(() => { mock = new MockAdapter(axios); mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {}); + mock.onGet(projectRefsEndpoint).reply(httpStatusCodes.OK, mockRefs); + + dummySubmitEvent = { + preventDefault: jest.fn(), + }; }); afterEach(() => { @@ -80,38 +81,17 @@ describe('Pipeline New Form', () => { mock.restore(); }); - describe('Dropdown with branches and tags', () => { - beforeEach(() => { - mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse); - }); - - it('displays dropdown with all branches and tags', () => { - const refLength = mockBranches.length + mockTags.length; - - createComponent(); - - expect(findDropdownItems()).toHaveLength(refLength); - }); - - it('when user enters search term the list is filtered', () => { - createComponent('master'); - - expect(findDropdownItems()).toHaveLength(1); - expect(findDropdownItems().at(0).text()).toBe('master'); - }); - }); - describe('Form', () => { beforeEach(async () => { - createComponent('', mockParams, mount); + createComponent(mockQueryParams, mount); - mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse); + mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse); await waitForPromises(); }); it('displays the correct values for the provided query params', async () => { - expect(findDropdown().props('text')).toBe('tag-1'); + expect(findRefsDropdown().props('value')).toEqual({ shortName: 'tag-1' }); expect(findVariableRows()).toHaveLength(3); }); @@ -152,11 +132,19 @@ describe('Pipeline New Form', () => { describe('Pipeline creation', () => { beforeEach(async () => { - mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse); + mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse); await waitForPromises(); }); + it('does not submit the native HTML form', async () => { + createComponent(); + + findForm().vm.$emit('submit', dummySubmitEvent); + + expect(dummySubmitEvent.preventDefault).toHaveBeenCalled(); + }); + it('disables the submit button immediately after submitting', async () => { createComponent(); @@ -171,19 +159,15 @@ describe('Pipeline New Form', () => { it('creates pipeline with full ref and variables', async () => { createComponent(); - changeRef(0); - findForm().vm.$emit('submit', dummySubmitEvent); - await waitForPromises(); - expect(getExpectedPostParams().ref).toEqual(wrapper.vm.$data.refValue.fullName); - expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${postResponse.id}`); + expect(getFormPostParams().ref).toEqual(`refs/heads/${defaultBranch}`); + expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`); }); - it('creates a pipeline with short ref and variables', async () => { - // query params are used - createComponent('', mockParams); + it('creates a pipeline with short ref and variables from the query params', async () => { + createComponent(mockQueryParams); await waitForPromises(); @@ -191,19 +175,19 @@ describe('Pipeline New Form', () => { await waitForPromises(); - expect(getExpectedPostParams()).toEqual(mockPostParams); - expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${postResponse.id}`); + expect(getFormPostParams()).toEqual(mockPostParams); + expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`); }); }); describe('When the ref has been changed', () => { beforeEach(async () => { - createComponent('', {}, mount); + createComponent({}, mount); await waitForPromises(); }); it('variables persist between ref changes', async () => { - changeRef(0); // change to master + selectBranch('master'); await waitForPromises(); @@ -213,7 +197,7 @@ describe('Pipeline New Form', () => { await wrapper.vm.$nextTick(); - changeRef(1); // change to branch-1 + selectBranch('branch-1'); await waitForPromises(); @@ -223,14 +207,14 @@ describe('Pipeline New Form', () => { await wrapper.vm.$nextTick(); - changeRef(0); // change back to master + selectBranch('master'); await waitForPromises(); expect(findKeyInputs().at(0).element.value).toBe('build_var'); expect(findVariableRows().length).toBe(2); - changeRef(1); // change back to branch-1 + selectBranch('branch-1'); await waitForPromises(); @@ -248,7 +232,7 @@ describe('Pipeline New Form', () => { const mockYmlDesc = 'A var from yml.'; it('loading icon is shown when content is requested and hidden when received', async () => { - createComponent('', mockParams, mount); + createComponent(mockQueryParams, mount); mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { [mockYmlKey]: { @@ -265,7 +249,7 @@ describe('Pipeline New Form', () => { }); it('multi-line strings are added to the value field without removing line breaks', async () => { - createComponent('', mockParams, mount); + createComponent(mockQueryParams, mount); mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { [mockYmlKey]: { @@ -281,7 +265,7 @@ describe('Pipeline New Form', () => { describe('with description', () => { beforeEach(async () => { - createComponent('', mockParams, mount); + createComponent(mockQueryParams, mount); mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { [mockYmlKey]: { @@ -323,7 +307,7 @@ describe('Pipeline New Form', () => { describe('without description', () => { beforeEach(async () => { - createComponent('', mockParams, mount); + createComponent(mockQueryParams, mount); mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { [mockYmlKey]: { @@ -346,6 +330,21 @@ describe('Pipeline New Form', () => { createComponent(); }); + describe('when the refs cannot be loaded', () => { + beforeEach(() => { + mock + .onGet(projectRefsEndpoint, { params: { search: '' } }) + .reply(httpStatusCodes.INTERNAL_SERVER_ERROR); + + findRefsDropdown().vm.$emit('loadingError'); + }); + + it('shows both an error alert', () => { + expect(findErrorAlert().exists()).toBe(true); + expect(findWarningAlert().exists()).toBe(false); + }); + }); + describe('when the error response can be handled', () => { beforeEach(async () => { mock.onPost(pipelinesPath).reply(httpStatusCodes.BAD_REQUEST, mockError); diff --git a/spec/frontend/pipeline_new/components/refs_dropdown_spec.js b/spec/frontend/pipeline_new/components/refs_dropdown_spec.js new file mode 100644 index 00000000000..8dafbf230f9 --- /dev/null +++ b/spec/frontend/pipeline_new/components/refs_dropdown_spec.js @@ -0,0 +1,182 @@ +import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; + +import RefsDropdown from '~/pipeline_new/components/refs_dropdown.vue'; + +import { mockRefs, mockFilteredRefs } from '../mock_data'; + +const projectRefsEndpoint = '/root/project/refs'; +const refShortName = 'master'; +const refFullName = 'refs/heads/master'; + +jest.mock('~/flash'); + +describe('Pipeline New Form', () => { + let wrapper; + let mock; + + const findDropdown = () => wrapper.find(GlDropdown); + const findRefsDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + + const createComponent = (props = {}, mountFn = shallowMount) => { + wrapper = mountFn(RefsDropdown, { + provide: { + projectRefsEndpoint, + }, + propsData: { + value: { + shortName: refShortName, + fullName: refFullName, + }, + ...props, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(projectRefsEndpoint, { params: { search: '' } }).reply(httpStatusCodes.OK, mockRefs); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + + mock.restore(); + }); + + beforeEach(() => { + createComponent(); + }); + + it('displays empty dropdown initially', async () => { + await findDropdown().vm.$emit('show'); + + expect(findRefsDropdownItems()).toHaveLength(0); + }); + + it('does not make requests immediately', async () => { + expect(mock.history.get).toHaveLength(0); + }); + + describe('when user opens dropdown', () => { + beforeEach(async () => { + await findDropdown().vm.$emit('show'); + await waitForPromises(); + }); + + it('requests unfiltered tags and branches', async () => { + expect(mock.history.get).toHaveLength(1); + expect(mock.history.get[0].url).toBe(projectRefsEndpoint); + expect(mock.history.get[0].params).toEqual({ search: '' }); + }); + + it('displays dropdown with branches and tags', async () => { + const refLength = mockRefs.Tags.length + mockRefs.Branches.length; + + expect(findRefsDropdownItems()).toHaveLength(refLength); + }); + + it('displays the names of refs', () => { + // Branches + expect(findRefsDropdownItems().at(0).text()).toBe(mockRefs.Branches[0]); + + // Tags (appear after branches) + const firstTag = mockRefs.Branches.length; + expect(findRefsDropdownItems().at(firstTag).text()).toBe(mockRefs.Tags[0]); + }); + + it('when user shows dropdown a second time, only one request is done', () => { + expect(mock.history.get).toHaveLength(1); + }); + + describe('when user selects a value', () => { + const selectedIndex = 1; + + beforeEach(async () => { + await findRefsDropdownItems().at(selectedIndex).vm.$emit('click'); + }); + + it('component emits @input', () => { + const inputs = wrapper.emitted('input'); + + expect(inputs).toHaveLength(1); + expect(inputs[0]).toEqual([{ shortName: 'branch-1', fullName: 'refs/heads/branch-1' }]); + }); + }); + + describe('when user types searches for a tag', () => { + const mockSearchTerm = 'my-search'; + + beforeEach(async () => { + mock + .onGet(projectRefsEndpoint, { params: { search: mockSearchTerm } }) + .reply(httpStatusCodes.OK, mockFilteredRefs); + + await findSearchBox().vm.$emit('input', mockSearchTerm); + await waitForPromises(); + }); + + it('requests filtered tags and branches', async () => { + expect(mock.history.get).toHaveLength(2); + expect(mock.history.get[1].params).toEqual({ + search: mockSearchTerm, + }); + }); + + it('displays dropdown with branches and tags', async () => { + const filteredRefLength = mockFilteredRefs.Tags.length + mockFilteredRefs.Branches.length; + + expect(findRefsDropdownItems()).toHaveLength(filteredRefLength); + }); + }); + }); + + describe('when user has selected a value', () => { + const selectedIndex = 1; + const mockShortName = mockRefs.Branches[selectedIndex]; + const mockFullName = `refs/heads/${mockShortName}`; + + beforeEach(async () => { + mock + .onGet(projectRefsEndpoint, { + params: { ref: mockFullName }, + }) + .reply(httpStatusCodes.OK, mockRefs); + + createComponent({ + value: { + shortName: mockShortName, + fullName: mockFullName, + }, + }); + await findDropdown().vm.$emit('show'); + await waitForPromises(); + }); + + it('branch is checked', () => { + expect(findRefsDropdownItems().at(selectedIndex).props('isChecked')).toBe(true); + }); + }); + + describe('when server returns an error', () => { + beforeEach(async () => { + mock + .onGet(projectRefsEndpoint, { params: { search: '' } }) + .reply(httpStatusCodes.INTERNAL_SERVER_ERROR); + + await findDropdown().vm.$emit('show'); + await waitForPromises(); + }); + + it('loading error event is emitted', () => { + expect(wrapper.emitted('loadingError')).toHaveLength(1); + expect(wrapper.emitted('loadingError')[0]).toEqual([expect.any(Error)]); + }); + }); +}); diff --git a/spec/frontend/pipeline_new/mock_data.js b/spec/frontend/pipeline_new/mock_data.js index feb24ec602d..4fb58cb8e62 100644 --- a/spec/frontend/pipeline_new/mock_data.js +++ b/spec/frontend/pipeline_new/mock_data.js @@ -1,16 +1,14 @@ -export const mockBranches = [ - { shortName: 'master', fullName: 'refs/heads/master' }, - { shortName: 'branch-1', fullName: 'refs/heads/branch-1' }, - { shortName: 'branch-2', fullName: 'refs/heads/branch-2' }, -]; +export const mockRefs = { + Branches: ['master', 'branch-1', 'branch-2'], + Tags: ['1.0.0', '1.1.0', '1.2.0'], +}; -export const mockTags = [ - { shortName: '1.0.0', fullName: 'refs/tags/1.0.0' }, - { shortName: '1.1.0', fullName: 'refs/tags/1.1.0' }, - { shortName: '1.2.0', fullName: 'refs/tags/1.2.0' }, -]; +export const mockFilteredRefs = { + Branches: ['branch-1'], + Tags: ['1.0.0', '1.1.0'], +}; -export const mockParams = { +export const mockQueryParams = { refParam: 'tag-1', variableParams: { test_var: 'test_var_val', diff --git a/spec/frontend/pipelines/components/pipelines_list/pipeline_mini_graph_spec.js b/spec/frontend/pipelines/components/pipelines_list/pipeline_mini_graph_spec.js new file mode 100644 index 00000000000..154828aff4b --- /dev/null +++ b/spec/frontend/pipelines/components/pipelines_list/pipeline_mini_graph_spec.js @@ -0,0 +1,83 @@ +import { shallowMount } from '@vue/test-utils'; +import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; +import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue'; + +const { pipelines } = getJSONFixture('pipelines/pipelines.json'); +const mockStages = pipelines[0].details.stages; + +describe('Pipeline Mini Graph', () => { + let wrapper; + + const findPipelineStages = () => wrapper.findAll(PipelineStage); + const findPipelineStagesAt = (i) => findPipelineStages().at(i); + + const createComponent = (props = {}) => { + wrapper = shallowMount(PipelineMiniGraph, { + propsData: { + stages: mockStages, + ...props, + }, + }); + }; + + it('renders stages', () => { + createComponent(); + + expect(findPipelineStages()).toHaveLength(mockStages.length); + }); + + it('renders stages with a custom class', () => { + createComponent({ stagesClass: 'my-class' }); + + expect(wrapper.findAll('.my-class')).toHaveLength(mockStages.length); + }); + + it('does not fail when stages are empty', () => { + createComponent({ stages: [] }); + + expect(wrapper.exists()).toBe(true); + expect(findPipelineStages()).toHaveLength(0); + }); + + it('triggers events in "action request complete" in stages', () => { + createComponent(); + + findPipelineStagesAt(0).vm.$emit('pipelineActionRequestComplete'); + findPipelineStagesAt(1).vm.$emit('pipelineActionRequestComplete'); + + expect(wrapper.emitted('pipelineActionRequestComplete')).toHaveLength(2); + }); + + it('update dropdown is false by default', () => { + createComponent(); + + expect(findPipelineStagesAt(0).props('updateDropdown')).toBe(false); + expect(findPipelineStagesAt(1).props('updateDropdown')).toBe(false); + }); + + it('update dropdown is set to true', () => { + createComponent({ updateDropdown: true }); + + expect(findPipelineStagesAt(0).props('updateDropdown')).toBe(true); + expect(findPipelineStagesAt(1).props('updateDropdown')).toBe(true); + }); + + it('is merge train is false by default', () => { + createComponent(); + + expect(findPipelineStagesAt(0).props('isMergeTrain')).toBe(false); + expect(findPipelineStagesAt(1).props('isMergeTrain')).toBe(false); + }); + + it('is merge train is set to true', () => { + createComponent({ isMergeTrain: true }); + + expect(findPipelineStagesAt(0).props('isMergeTrain')).toBe(true); + expect(findPipelineStagesAt(1).props('isMergeTrain')).toBe(true); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); +}); diff --git a/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js b/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js new file mode 100644 index 00000000000..60026f69b84 --- /dev/null +++ b/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js @@ -0,0 +1,210 @@ +import { GlDropdown } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue'; +import eventHub from '~/pipelines/event_hub'; +import { stageReply } from '../../mock_data'; + +const dropdownPath = 'path.json'; + +describe('Pipelines stage component', () => { + let wrapper; + let mock; + + const createComponent = (props = {}) => { + wrapper = mount(PipelineStage, { + attachTo: document.body, + propsData: { + stage: { + status: { + group: 'success', + icon: 'status_success', + title: 'success', + }, + dropdown_path: dropdownPath, + }, + updateDropdown: false, + ...props, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + jest.spyOn(eventHub, '$emit'); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + + eventHub.$emit.mockRestore(); + mock.restore(); + }); + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownToggle = () => wrapper.find('button.dropdown-toggle'); + const findDropdownMenu = () => + wrapper.find('[data-testid="mini-pipeline-graph-dropdown-menu-list"]'); + const findCiActionBtn = () => wrapper.find('.js-ci-action'); + const findMergeTrainWarning = () => wrapper.find('[data-testid="warning-message-merge-trains"]'); + + const openStageDropdown = () => { + findDropdownToggle().trigger('click'); + return new Promise((resolve) => { + wrapper.vm.$root.$on('bv::dropdown::show', resolve); + }); + }; + + describe('default appearance', () => { + beforeEach(() => { + createComponent(); + }); + + it('should render a dropdown with the status icon', () => { + expect(findDropdown().exists()).toBe(true); + expect(findDropdownToggle().exists()).toBe(true); + expect(wrapper.find('[data-testid="status_success_borderless-icon"]').exists()).toBe(true); + }); + }); + + describe('when update dropdown is changed', () => { + beforeEach(() => { + createComponent(); + }); + }); + + describe('when user opens dropdown and stage request is successful', () => { + beforeEach(async () => { + mock.onGet(dropdownPath).reply(200, stageReply); + createComponent(); + + await openStageDropdown(); + await axios.waitForAll(); + }); + + it('should render the received data and emit `clickedDropdown` event', async () => { + expect(findDropdownMenu().text()).toContain(stageReply.latest_statuses[0].name); + expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); + }); + + it('should refresh when updateDropdown is set to true', async () => { + expect(mock.history.get).toHaveLength(1); + + wrapper.setProps({ updateDropdown: true }); + await axios.waitForAll(); + + expect(mock.history.get).toHaveLength(2); + }); + }); + + describe('when user opens dropdown and stage request fails', () => { + beforeEach(async () => { + mock.onGet(dropdownPath).reply(500); + createComponent(); + + await openStageDropdown(); + await axios.waitForAll(); + }); + + it('should close the dropdown', () => { + expect(findDropdown().classes('show')).toBe(false); + }); + }); + + describe('update endpoint correctly', () => { + beforeEach(async () => { + const copyStage = { ...stageReply }; + copyStage.latest_statuses[0].name = 'this is the updated content'; + mock.onGet('bar.json').reply(200, copyStage); + createComponent({ + stage: { + status: { + group: 'running', + icon: 'status_running', + title: 'running', + }, + dropdown_path: 'bar.json', + }, + }); + await axios.waitForAll(); + }); + + it('should update the stage to request the new endpoint provided', async () => { + await openStageDropdown(); + await axios.waitForAll(); + + expect(findDropdownMenu().text()).toContain('this is the updated content'); + }); + }); + + describe('pipelineActionRequestComplete', () => { + beforeEach(() => { + mock.onGet(dropdownPath).reply(200, stageReply); + mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200); + + createComponent(); + }); + + const clickCiAction = async () => { + await openStageDropdown(); + await axios.waitForAll(); + + findCiActionBtn().trigger('click'); + await axios.waitForAll(); + }; + + it('closes dropdown when job item action is clicked', async () => { + const hidden = jest.fn(); + + wrapper.vm.$root.$on('bv::dropdown::hide', hidden); + + expect(hidden).toHaveBeenCalledTimes(0); + + await clickCiAction(); + + expect(hidden).toHaveBeenCalledTimes(1); + }); + + it('emits `pipelineActionRequestComplete` when job item action is clicked', async () => { + await clickCiAction(); + + expect(wrapper.emitted('pipelineActionRequestComplete')).toHaveLength(1); + }); + }); + + describe('With merge trains enabled', () => { + beforeEach(async () => { + mock.onGet(dropdownPath).reply(200, stageReply); + createComponent({ + isMergeTrain: true, + }); + + await openStageDropdown(); + await axios.waitForAll(); + }); + + it('shows a warning on the dropdown', () => { + const warning = findMergeTrainWarning(); + + expect(warning.text()).toBe('Merge train pipeline jobs can not be retried'); + }); + }); + + describe('With merge trains disabled', () => { + beforeEach(async () => { + mock.onGet(dropdownPath).reply(200, stageReply); + createComponent(); + + await openStageDropdown(); + await axios.waitForAll(); + }); + + it('does not show a warning on the dropdown', () => { + const warning = findMergeTrainWarning(); + + expect(warning.exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js index 3ebedc9ac87..912bc7a104a 100644 --- a/spec/frontend/pipelines/empty_state_spec.js +++ b/spec/frontend/pipelines/empty_state_spec.js @@ -1,24 +1,25 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue'; describe('Pipelines Empty State', () => { let wrapper; - const findGetStartedButton = () => wrapper.find('[data-testid="get-started-pipelines"]'); - const findInfoText = () => wrapper.find('[data-testid="info-text"]').text(); - const createWrapper = () => { - wrapper = shallowMount(EmptyState, { + const findIllustration = () => wrapper.find('img'); + const findButton = () => wrapper.find('a'); + + const createWrapper = (props = {}) => { + wrapper = mount(EmptyState, { propsData: { - helpPagePath: 'foo', - emptyStateSvgPath: 'foo', + emptyStateSvgPath: 'foo.svg', canSetCi: true, + ...props, }, }); }; - describe('renders', () => { + describe('when user can configure CI', () => { beforeEach(() => { - createWrapper(); + createWrapper({}, mount); }); afterEach(() => { @@ -27,26 +28,49 @@ describe('Pipelines Empty State', () => { }); it('should render empty state SVG', () => { - expect(wrapper.find('img').attributes('src')).toBe('foo'); + expect(findIllustration().attributes('src')).toBe('foo.svg'); }); it('should render empty state header', () => { - expect(wrapper.find('[data-testid="header-text"]').text()).toBe('Build with confidence'); - }); - - it('should render a link with provided help path', () => { - expect(findGetStartedButton().attributes('href')).toBe('foo'); + expect(wrapper.text()).toContain('Build with confidence'); }); it('should render empty state information', () => { - expect(findInfoText()).toContain( + expect(wrapper.text()).toContain( 'GitLab CI/CD can automatically build, test, and deploy your code. Let GitLab take care of time', 'consuming tasks, so you can spend more time creating', ); }); + it('should render button with help path', () => { + expect(findButton().attributes('href')).toBe('/help/ci/quick_start/index.md'); + }); + it('should render button text', () => { - expect(findGetStartedButton().text()).toBe('Get started with CI/CD'); + expect(findButton().text()).toBe('Get started with CI/CD'); + }); + }); + + describe('when user cannot configure CI', () => { + beforeEach(() => { + createWrapper({ canSetCi: false }, mount); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('should render empty state SVG', () => { + expect(findIllustration().attributes('src')).toBe('foo.svg'); + }); + + it('should render empty state header', () => { + expect(wrapper.text()).toBe('This project is not currently set up to run pipelines.'); + }); + + it('should not render a link', () => { + expect(findButton().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index 3e8d4ba314c..6c3f848333c 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -20,6 +20,10 @@ describe('graph component', () => { const defaultProps = { pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'), + configPaths: { + metricsPath: '', + graphqlResourceEtag: 'this/is/a/path', + }, }; const defaultData = { diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index 202365ecd35..44d8e467f51 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -9,6 +9,8 @@ import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_w import { mockPipelineResponse } from './mock_data'; const defaultProvide = { + graphqlResourceEtag: 'frog/amphibirama/etag/', + metricsPath: '', pipelineProjectPath: 'frog/amphibirama', pipelineIid: '22', }; @@ -87,6 +89,13 @@ describe('Pipeline graph wrapper', () => { it('displays the graph', () => { expect(getGraph().exists()).toBe(true); }); + + it('passes the etag resource and metrics path to the graph', () => { + expect(getGraph().props('configPaths')).toMatchObject({ + graphqlResourceEtag: defaultProvide.graphqlResourceEtag, + metricsPath: defaultProvide.metricsPath, + }); + }); }); describe('when there is an error', () => { @@ -121,4 +130,48 @@ describe('Pipeline graph wrapper', () => { expect(wrapper.vm.$apollo.queries.pipeline.refetch).toHaveBeenCalled(); }); }); + + describe('when query times out', () => { + const advanceApolloTimers = async () => { + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + }; + + beforeEach(async () => { + const errorData = { + data: { + project: { + pipelines: null, + }, + }, + errors: [{ message: 'timeout' }], + }; + + const failSucceedFail = jest + .fn() + .mockResolvedValueOnce(errorData) + .mockResolvedValueOnce(mockPipelineResponse) + .mockResolvedValueOnce(errorData); + + createComponentWithApollo(failSucceedFail); + await wrapper.vm.$nextTick(); + }); + + it('shows correct errors and does not overwrite populated data when data is empty', async () => { + /* fails at first, shows error, no data yet */ + expect(getAlert().exists()).toBe(true); + expect(getGraph().exists()).toBe(false); + + /* succeeds, clears error, shows graph */ + await advanceApolloTimers(); + expect(getAlert().exists()).toBe(false); + expect(getGraph().exists()).toBe(true); + + /* fails again, alert returns but data persists */ + await advanceApolloTimers(); + expect(getAlert().exists()).toBe(true); + expect(getGraph().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js index 8f01accccc1..4c72dad735e 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js @@ -20,6 +20,10 @@ describe('Linked Pipelines Column', () => { columnTitle: 'Downstream', linkedPipelines: processedPipeline.downstream, type: DOWNSTREAM, + configPaths: { + metricsPath: '', + graphqlResourceEtag: 'this/is/a/path', + }, }; let wrapper; @@ -112,7 +116,7 @@ describe('Linked Pipelines Column', () => { it('emits the error', async () => { await clickExpandButton(); - expect(wrapper.emitted().error).toEqual([[LOAD_FAILURE]]); + expect(wrapper.emitted().error).toEqual([[{ type: LOAD_FAILURE, skipSentry: true }]]); }); it('does not show the pipeline', async () => { @@ -163,7 +167,7 @@ describe('Linked Pipelines Column', () => { it('emits the error', async () => { await clickExpandButton(); - expect(wrapper.emitted().error).toEqual([[LOAD_FAILURE]]); + expect(wrapper.emitted().error).toEqual([[{ type: LOAD_FAILURE, skipSentry: true }]]); }); it('does not show the pipeline', async () => { diff --git a/spec/frontend/pipelines/graph_shared/links_inner_spec.js b/spec/frontend/pipelines/graph_shared/links_inner_spec.js index 6cabe2bc8a7..6fef1c9b62e 100644 --- a/spec/frontend/pipelines/graph_shared/links_inner_spec.js +++ b/spec/frontend/pipelines/graph_shared/links_inner_spec.js @@ -1,5 +1,15 @@ import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; import { setHTMLFixture } from 'helpers/fixtures'; +import axios from '~/lib/utils/axios_utils'; +import { + PIPELINES_DETAIL_LINK_DURATION, + PIPELINES_DETAIL_LINKS_TOTAL, + PIPELINES_DETAIL_LINKS_JOB_RATIO, +} from '~/performance/constants'; +import * as perfUtils from '~/performance/utils'; +import * as sentryUtils from '~/pipelines/components/graph/utils'; +import * as Api from '~/pipelines/components/graph_shared/api'; import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; import { createJobsHash } from '~/pipelines/utils'; import { @@ -18,7 +28,9 @@ describe('Links Inner component', () => { containerMeasurements: { width: 1019, height: 445 }, pipelineId: 1, pipelineData: [], + totalGroups: 10, }; + let wrapper; const createComponent = (props) => { @@ -194,4 +206,141 @@ describe('Links Inner component', () => { expect(firstLink.classes(hoverColorClass)).toBe(true); }); }); + + describe('performance metrics', () => { + let markAndMeasure; + let reportToSentry; + let reportPerformance; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb()); + markAndMeasure = jest.spyOn(perfUtils, 'performanceMarkAndMeasure'); + reportToSentry = jest.spyOn(sentryUtils, 'reportToSentry'); + reportPerformance = jest.spyOn(Api, 'reportPerformance'); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('with no metrics config object', () => { + beforeEach(() => { + setFixtures(pipelineData); + createComponent({ + pipelineData: pipelineData.stages, + }); + }); + + it('is not called', () => { + expect(markAndMeasure).not.toHaveBeenCalled(); + expect(reportToSentry).not.toHaveBeenCalled(); + expect(reportPerformance).not.toHaveBeenCalled(); + }); + }); + + describe('with metrics config set to false', () => { + beforeEach(() => { + setFixtures(pipelineData); + createComponent({ + pipelineData: pipelineData.stages, + metricsConfig: { + collectMetrics: false, + metricsPath: '/path/to/metrics', + }, + }); + }); + + it('is not called', () => { + expect(markAndMeasure).not.toHaveBeenCalled(); + expect(reportToSentry).not.toHaveBeenCalled(); + expect(reportPerformance).not.toHaveBeenCalled(); + }); + }); + + describe('with no metrics path', () => { + beforeEach(() => { + setFixtures(pipelineData); + createComponent({ + pipelineData: pipelineData.stages, + metricsConfig: { + collectMetrics: true, + metricsPath: '', + }, + }); + }); + + it('is not called', () => { + expect(markAndMeasure).not.toHaveBeenCalled(); + expect(reportToSentry).not.toHaveBeenCalled(); + expect(reportPerformance).not.toHaveBeenCalled(); + }); + }); + + describe('with metrics path and collect set to true', () => { + const metricsPath = '/root/project/-/ci/prometheus_metrics/histograms.json'; + const duration = 0.0478; + const numLinks = 1; + const metricsData = { + histograms: [ + { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 }, + { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks }, + { + name: PIPELINES_DETAIL_LINKS_JOB_RATIO, + value: numLinks / defaultProps.totalGroups, + }, + ], + }; + + describe('when no duration is obtained', () => { + beforeEach(() => { + jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => { + return []; + }); + + setFixtures(pipelineData); + + createComponent({ + pipelineData: pipelineData.stages, + metricsConfig: { + collectMetrics: true, + path: metricsPath, + }, + }); + }); + + it('attempts to collect metrics', () => { + expect(markAndMeasure).toHaveBeenCalled(); + expect(reportPerformance).not.toHaveBeenCalled(); + expect(reportToSentry).not.toHaveBeenCalled(); + }); + }); + + describe('with duration and no error', () => { + beforeEach(() => { + jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => { + return [{ duration }]; + }); + + setFixtures(pipelineData); + + createComponent({ + pipelineData: pipelineData.stages, + metricsConfig: { + collectMetrics: true, + path: metricsPath, + }, + }); + }); + + it('it calls reportPerformance with expected arguments', () => { + expect(markAndMeasure).toHaveBeenCalled(); + expect(reportPerformance).toHaveBeenCalled(); + expect(reportPerformance).toHaveBeenCalledWith(metricsPath, metricsData); + expect(reportToSentry).not.toHaveBeenCalled(); + }); + }); + }); + }); }); diff --git a/spec/frontend/pipelines/graph_shared/links_layer_spec.js b/spec/frontend/pipelines/graph_shared/links_layer_spec.js index 0ff8583fbff..43d8fe28893 100644 --- a/spec/frontend/pipelines/graph_shared/links_layer_spec.js +++ b/spec/frontend/pipelines/graph_shared/links_layer_spec.js @@ -79,6 +79,24 @@ describe('links layer component', () => { }); }); + describe('with width or height measurement at 0', () => { + beforeEach(() => { + createComponent({ props: { containerMeasurements: { width: 0, height: 100 } } }); + }); + + it('renders the default slot', () => { + expect(wrapper.html()).toContain(slotContent); + }); + + it('does not render the alert component', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('does not render the inner links component', () => { + expect(findLinksInner().exists()).toBe(false); + }); + }); + describe('interactions', () => { beforeEach(() => { createComponent({ mountFn: mount, props: { pipelineData: tooManyStages } }); diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js index 2afdbb05107..337838c41b3 100644 --- a/spec/frontend/pipelines/mock_data.js +++ b/spec/frontend/pipelines/mock_data.js @@ -2,328 +2,6 @@ const PIPELINE_RUNNING = 'RUNNING'; const PIPELINE_CANCELED = 'CANCELED'; const PIPELINE_FAILED = 'FAILED'; -export const pipelineWithStages = { - id: 20333396, - user: { - id: 128633, - name: 'Rémy Coutable', - username: 'rymai', - state: 'active', - avatar_url: - 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon', - web_url: 'https://gitlab.com/rymai', - path: '/rymai', - }, - active: true, - coverage: '58.24', - source: 'push', - created_at: '2018-04-11T14:04:53.881Z', - updated_at: '2018-04-11T14:05:00.792Z', - path: '/gitlab-org/gitlab/pipelines/20333396', - flags: { - latest: true, - stuck: false, - auto_devops: false, - yaml_errors: false, - retryable: false, - cancelable: true, - failure_reason: false, - }, - details: { - status: { - icon: 'status_running', - text: 'running', - label: 'running', - group: 'running', - has_details: true, - details_path: '/gitlab-org/gitlab/pipelines/20333396', - favicon: - 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_running-2eb56be2871937954b2ba6d6f4ee9fdf7e5e1c146ac45f7be98119ccaca1aca9.ico', - }, - duration: null, - finished_at: null, - stages: [ - { - name: 'build', - title: 'build: skipped', - status: { - icon: 'status_skipped', - text: 'skipped', - label: 'skipped', - group: 'skipped', - has_details: true, - details_path: '/gitlab-org/gitlab/pipelines/20333396#build', - favicon: - 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_skipped-a2eee568a5bffdb494050c7b62dde241de9189280836288ac8923d369f16222d.ico', - }, - path: '/gitlab-org/gitlab/pipelines/20333396#build', - dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=build', - }, - { - name: 'prepare', - title: 'prepare: passed', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/gitlab-org/gitlab/pipelines/20333396#prepare', - favicon: - 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_success-26f59841becbef8c6fe414e9e74471d8bfd6a91b5855c19fe7f5923a40a7da47.ico', - }, - path: '/gitlab-org/gitlab/pipelines/20333396#prepare', - dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=prepare', - }, - { - name: 'test', - title: 'test: running', - status: { - icon: 'status_running', - text: 'running', - label: 'running', - group: 'running', - has_details: true, - details_path: '/gitlab-org/gitlab/pipelines/20333396#test', - favicon: - 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_running-2eb56be2871937954b2ba6d6f4ee9fdf7e5e1c146ac45f7be98119ccaca1aca9.ico', - }, - path: '/gitlab-org/gitlab/pipelines/20333396#test', - dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=test', - }, - { - name: 'post-test', - title: 'post-test: created', - status: { - icon: 'status_created', - text: 'created', - label: 'created', - group: 'created', - has_details: true, - details_path: '/gitlab-org/gitlab/pipelines/20333396#post-test', - favicon: - 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico', - }, - path: '/gitlab-org/gitlab/pipelines/20333396#post-test', - dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=post-test', - }, - { - name: 'pages', - title: 'pages: created', - status: { - icon: 'status_created', - text: 'created', - label: 'created', - group: 'created', - has_details: true, - details_path: '/gitlab-org/gitlab/pipelines/20333396#pages', - favicon: - 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico', - }, - path: '/gitlab-org/gitlab/pipelines/20333396#pages', - dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=pages', - }, - { - name: 'post-cleanup', - title: 'post-cleanup: created', - status: { - icon: 'status_created', - text: 'created', - label: 'created', - group: 'created', - has_details: true, - details_path: '/gitlab-org/gitlab/pipelines/20333396#post-cleanup', - favicon: - 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico', - }, - path: '/gitlab-org/gitlab/pipelines/20333396#post-cleanup', - dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=post-cleanup', - }, - ], - artifacts: [ - { - name: 'gitlab:assets:compile', - expired: false, - expire_at: '2018-05-12T14:22:54.730Z', - path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/browse', - }, - { - name: 'rspec-mysql 12 28', - expired: false, - expire_at: '2018-05-12T14:22:45.136Z', - path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/browse', - }, - { - name: 'rspec-mysql 6 28', - expired: false, - expire_at: '2018-05-12T14:22:41.523Z', - path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/browse', - }, - { - name: 'rspec-pg geo 0 1', - expired: false, - expire_at: '2018-05-12T14:22:13.287Z', - path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/browse', - }, - { - name: 'rspec-mysql 0 28', - expired: false, - expire_at: '2018-05-12T14:22:06.834Z', - path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/browse', - }, - { - name: 'spinach-mysql 0 2', - expired: false, - expire_at: '2018-05-12T14:21:51.409Z', - path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/browse', - }, - { - name: 'karma', - expired: false, - expire_at: '2018-05-12T14:21:20.934Z', - path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/browse', - }, - { - name: 'spinach-pg 0 2', - expired: false, - expire_at: '2018-05-12T14:20:01.028Z', - path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/browse', - }, - { - name: 'spinach-pg 1 2', - expired: false, - expire_at: '2018-05-12T14:19:04.336Z', - path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/browse', - }, - { - name: 'sast', - expired: null, - expire_at: null, - path: '/gitlab-org/gitlab/-/jobs/62411442/artifacts/download', - browse_path: '/gitlab-org/gitlab/-/jobs/62411442/artifacts/browse', - }, - { - name: 'code_quality', - expired: false, - expire_at: '2018-04-18T14:16:24.484Z', - path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/browse', - }, - { - name: 'cache gems', - expired: null, - expire_at: null, - path: '/gitlab-org/gitlab/-/jobs/62411447/artifacts/download', - browse_path: '/gitlab-org/gitlab/-/jobs/62411447/artifacts/browse', - }, - { - name: 'dependency_scanning', - expired: null, - expire_at: null, - path: '/gitlab-org/gitlab/-/jobs/62411443/artifacts/download', - browse_path: '/gitlab-org/gitlab/-/jobs/62411443/artifacts/browse', - }, - { - name: 'compile-assets', - expired: false, - expire_at: '2018-04-18T14:12:07.638Z', - path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/browse', - }, - { - name: 'setup-test-env', - expired: false, - expire_at: '2018-04-18T14:10:27.024Z', - path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/browse', - }, - { - name: 'retrieve-tests-metadata', - expired: false, - expire_at: '2018-05-12T14:06:35.926Z', - path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/download', - keep_path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/keep', - browse_path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/browse', - }, - ], - manual_actions: [ - { - name: 'package-and-qa', - path: '/gitlab-org/gitlab/-/jobs/62411330/play', - playable: true, - }, - { - name: 'review-docs-deploy', - path: '/gitlab-org/gitlab/-/jobs/62411332/play', - playable: true, - }, - ], - }, - ref: { - name: 'master', - path: '/gitlab-org/gitlab/commits/master', - tag: false, - branch: true, - }, - commit: { - id: 'e6a2885c503825792cb8a84a8731295e361bd059', - short_id: 'e6a2885c', - title: "Merge branch 'ce-to-ee-2018-04-11' into 'master'", - created_at: '2018-04-11T14:04:39.000Z', - parent_ids: [ - '5d9b5118f6055f72cff1a82b88133609912f2c1d', - '6fdc6ee76a8062fe41b1a33f7c503334a6ebdc02', - ], - message: - "Merge branch 'ce-to-ee-2018-04-11' into 'master'\n\nCE upstream - 2018-04-11 12:26 UTC\n\nSee merge request gitlab-org/gitlab-ee!5326", - author_name: 'Rémy Coutable', - author_email: 'remy@rymai.me', - authored_date: '2018-04-11T14:04:39.000Z', - committer_name: 'Rémy Coutable', - committer_email: 'remy@rymai.me', - committed_date: '2018-04-11T14:04:39.000Z', - author: { - id: 128633, - name: 'Rémy Coutable', - username: 'rymai', - state: 'active', - avatar_url: - 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon', - web_url: 'https://gitlab.com/rymai', - path: '/rymai', - }, - author_gravatar_url: - 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon', - commit_url: - 'https://gitlab.com/gitlab-org/gitlab/commit/e6a2885c503825792cb8a84a8731295e361bd059', - commit_path: '/gitlab-org/gitlab/commit/e6a2885c503825792cb8a84a8731295e361bd059', - }, - cancel_path: '/gitlab-org/gitlab/pipelines/20333396/cancel', - triggered_by: null, - triggered: [], -}; - const threeWeeksAgo = new Date(); threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); diff --git a/spec/frontend/pipelines/pipeline_triggerer_spec.js b/spec/frontend/pipelines/pipeline_triggerer_spec.js index 467a97d95c7..ffb2721f159 100644 --- a/spec/frontend/pipelines/pipeline_triggerer_spec.js +++ b/spec/frontend/pipelines/pipeline_triggerer_spec.js @@ -35,8 +35,8 @@ describe('Pipelines Triggerer', () => { wrapper.destroy(); }); - it('should render a table cell', () => { - expect(wrapper.find('.table-section').exists()).toBe(true); + it('should render pipeline triggerer table cell', () => { + expect(wrapper.find('[data-testid="pipeline-triggerer"]').exists()).toBe(true); }); it('should pass triggerer information when triggerer is provided', () => { diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js index 44c9def99cc..367c7f2b2f6 100644 --- a/spec/frontend/pipelines/pipeline_url_spec.js +++ b/spec/frontend/pipelines/pipeline_url_spec.js @@ -1,19 +1,20 @@ import { shallowMount } from '@vue/test-utils'; -import $ from 'jquery'; import { trimText } from 'helpers/text_helper'; import PipelineUrlComponent from '~/pipelines/components/pipelines_list/pipeline_url.vue'; -$.fn.popover = () => {}; +const projectPath = 'test/test'; describe('Pipeline Url Component', () => { let wrapper; + const findTableCell = () => wrapper.find('[data-testid="pipeline-url-table-cell"]'); const findPipelineUrlLink = () => wrapper.find('[data-testid="pipeline-url-link"]'); const findScheduledTag = () => wrapper.find('[data-testid="pipeline-url-scheduled"]'); const findLatestTag = () => wrapper.find('[data-testid="pipeline-url-latest"]'); const findYamlTag = () => wrapper.find('[data-testid="pipeline-url-yaml"]'); const findFailureTag = () => wrapper.find('[data-testid="pipeline-url-failure"]'); const findAutoDevopsTag = () => wrapper.find('[data-testid="pipeline-url-autodevops"]'); + const findAutoDevopsTagLink = () => wrapper.find('[data-testid="pipeline-url-autodevops-link"]'); const findStuckTag = () => wrapper.find('[data-testid="pipeline-url-stuck"]'); const findDetachedTag = () => wrapper.find('[data-testid="pipeline-url-detached"]'); const findForkTag = () => wrapper.find('[data-testid="pipeline-url-fork"]'); @@ -23,9 +24,9 @@ describe('Pipeline Url Component', () => { pipeline: { id: 1, path: 'foo', + project: { full_path: `/${projectPath}` }, flags: {}, }, - autoDevopsHelpPath: 'foo', pipelineScheduleUrl: 'foo', }; @@ -33,7 +34,7 @@ describe('Pipeline Url Component', () => { wrapper = shallowMount(PipelineUrlComponent, { propsData: { ...defaultProps, ...props }, provide: { - targetProjectFullPath: 'test/test', + targetProjectFullPath: projectPath, }, }); }; @@ -43,10 +44,10 @@ describe('Pipeline Url Component', () => { wrapper = null; }); - it('should render a table cell', () => { + it('should render pipeline url table cell', () => { createComponent(); - expect(wrapper.attributes('class')).toContain('table-section'); + expect(findTableCell().exists()).toBe(true); }); it('should render a link the provided path and id', () => { @@ -57,6 +58,19 @@ describe('Pipeline Url Component', () => { expect(findPipelineUrlLink().text()).toBe('#1'); }); + it('should not render tags when flags are not set', () => { + createComponent(); + + expect(findStuckTag().exists()).toBe(false); + expect(findLatestTag().exists()).toBe(false); + expect(findYamlTag().exists()).toBe(false); + expect(findAutoDevopsTag().exists()).toBe(false); + expect(findFailureTag().exists()).toBe(false); + expect(findScheduledTag().exists()).toBe(false); + expect(findForkTag().exists()).toBe(false); + expect(findTrainTag().exists()).toBe(false); + }); + it('should render the stuck tag when flag is provided', () => { createComponent({ pipeline: { @@ -96,6 +110,7 @@ describe('Pipeline Url Component', () => { it('should render an autodevops badge when flag is provided', () => { createComponent({ pipeline: { + ...defaultProps.pipeline, flags: { auto_devops: true, }, @@ -103,6 +118,11 @@ describe('Pipeline Url Component', () => { }); expect(trimText(findAutoDevopsTag().text())).toBe('Auto DevOps'); + + expect(findAutoDevopsTagLink().attributes()).toMatchObject({ + href: '/help/topics/autodevops/index.md', + target: '_blank', + }); }); it('should render a detached badge when flag is provided', () => { @@ -147,7 +167,7 @@ describe('Pipeline Url Component', () => { createComponent({ pipeline: { flags: {}, - project: { fullPath: 'test/forked' }, + project: { fullPath: '/test/forked' }, }, }); diff --git a/spec/frontend/pipelines/pipelines_actions_spec.js b/spec/frontend/pipelines/pipelines_actions_spec.js index 1e6c9e50a7e..c4bfec8ae14 100644 --- a/spec/frontend/pipelines/pipelines_actions_spec.js +++ b/spec/frontend/pipelines/pipelines_actions_spec.js @@ -1,11 +1,11 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import PipelinesActions from '~/pipelines/components/pipelines_list/pipelines_actions.vue'; +import PipelinesManualActions from '~/pipelines/components/pipelines_list/pipelines_manual_actions.vue'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; jest.mock('~/flash'); @@ -15,7 +15,7 @@ describe('Pipelines Actions dropdown', () => { let mock; const createComponent = (props, mountFn = shallowMount) => { - wrapper = mountFn(PipelinesActions, { + wrapper = mountFn(PipelinesManualActions, { propsData: { ...props, }, @@ -63,10 +63,6 @@ describe('Pipelines Actions dropdown', () => { }); describe('on click', () => { - beforeEach(() => { - createComponent({ actions: mockActions }, mount); - }); - it('makes a request and toggles the loading state', async () => { mock.onPost(mockActions.path).reply(200); diff --git a/spec/frontend/pipelines/pipelines_artifacts_spec.js b/spec/frontend/pipelines/pipelines_artifacts_spec.js index f077833ae16..d4a2db08d97 100644 --- a/spec/frontend/pipelines/pipelines_artifacts_spec.js +++ b/spec/frontend/pipelines/pipelines_artifacts_spec.js @@ -1,24 +1,27 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; describe('Pipelines Artifacts dropdown', () => { let wrapper; const createComponent = () => { - wrapper = mount(PipelineArtifacts, { + wrapper = shallowMount(PipelineArtifacts, { propsData: { artifacts: [ { - name: 'artifact', + name: 'job my-artifact', path: '/download/path', }, { - name: 'artifact two', + name: 'job-2 my-artifact-2', path: '/download/path-two', }, ], }, + stubs: { + GlSprintf, + }, }); }; @@ -39,8 +42,8 @@ describe('Pipelines Artifacts dropdown', () => { }); it('should render a link with the provided path', () => { - expect(findFirstGlDropdownItem().find('a').attributes('href')).toEqual('/download/path'); + expect(findFirstGlDropdownItem().attributes('href')).toBe('/download/path'); - expect(findFirstGlDropdownItem().text()).toContain('artifact'); + expect(findFirstGlDropdownItem().text()).toBe('Download job my-artifact artifact'); }); }); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 811303a5624..b04880b43ae 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -1,4 +1,4 @@ -import { GlFilteredSearch, GlButton, GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { GlButton, GlFilteredSearch, GlLoadingIcon, GlPagination } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { chunk } from 'lodash'; @@ -18,7 +18,7 @@ import Store from '~/pipelines/stores/pipelines_store'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; -import { pipelineWithStages, stageReply, users, mockSearch, branches } from './mock_data'; +import { stageReply, users, mockSearch, branches } from './mock_data'; jest.mock('~/flash'); @@ -27,6 +27,9 @@ const mockProjectId = '21'; const mockPipelinesEndpoint = `/${mockProjectPath}/pipelines.json`; const mockPipelinesResponse = getJSONFixture('pipelines/pipelines.json'); const mockPipelinesIds = mockPipelinesResponse.pipelines.map(({ id }) => id); +const mockPipelineWithStages = mockPipelinesResponse.pipelines.find( + (p) => p.details.stages && p.details.stages.length, +); describe('Pipelines', () => { let wrapper; @@ -34,8 +37,6 @@ describe('Pipelines', () => { let origWindowLocation; const paths = { - autoDevopsHelpPath: '/help/topics/autodevops/index.md', - helpPagePath: '/help/ci/quick_start/README', emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg', errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg', @@ -45,8 +46,6 @@ describe('Pipelines', () => { }; const noPermissions = { - autoDevopsHelpPath: '/help/topics/autodevops/index.md', - helpPagePath: '/help/ci/quick_start/README', emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg', errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg', @@ -70,7 +69,8 @@ describe('Pipelines', () => { const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button'); const findCiLintButton = () => wrapper.findByTestId('ci-lint-button'); const findCleanCacheButton = () => wrapper.findByTestId('clear-cache-button'); - const findStagesDropdown = () => wrapper.findByTestId('mini-pipeline-graph-dropdown-toggle'); + const findStagesDropdownToggle = () => + wrapper.find('[data-testid="mini-pipeline-graph-dropdown"] .dropdown-toggle'); const findPipelineUrlLinks = () => wrapper.findAll('[data-testid="pipeline-url-link"]'); const createComponent = (props = defaultProps) => { @@ -539,14 +539,15 @@ describe('Pipelines', () => { }); it('renders empty state', () => { - expect(findEmptyState().find('[data-testid="header-text"]').text()).toBe( - 'Build with confidence', - ); - expect(findEmptyState().find('[data-testid="info-text"]').text()).toContain( + expect(findEmptyState().text()).toContain('Build with confidence'); + expect(findEmptyState().text()).toContain( 'GitLab CI/CD can automatically build, test, and deploy your code.', ); + expect(findEmptyState().find(GlButton).text()).toBe('Get started with CI/CD'); - expect(findEmptyState().find(GlButton).attributes('href')).toBe(paths.helpPagePath); + expect(findEmptyState().find(GlButton).attributes('href')).toBe( + '/help/ci/quick_start/index.md', + ); }); it('does not render tabs nor buttons', () => { @@ -613,14 +614,15 @@ describe('Pipelines', () => { mock.onGet(mockPipelinesEndpoint, { scope: 'all', page: '1' }).reply( 200, { - pipelines: [pipelineWithStages], + pipelines: [mockPipelineWithStages], count: { all: '1' }, }, { 'POLL-INTERVAL': 100, }, ); - mock.onGet(pipelineWithStages.details.stages[0].dropdown_path).reply(200, stageReply); + + mock.onGet(mockPipelineWithStages.details.stages[0].dropdown_path).reply(200, stageReply); createComponent(); @@ -640,7 +642,7 @@ describe('Pipelines', () => { // Mock init a polling cycle wrapper.vm.poll.options.notificationCallback(true); - findStagesDropdown().trigger('click'); + findStagesDropdownToggle().trigger('click'); await waitForPromises(); @@ -650,7 +652,9 @@ describe('Pipelines', () => { }); it('stops polling & restarts polling', async () => { - findStagesDropdown().trigger('click'); + findStagesDropdownToggle().trigger('click'); + + await waitForPromises(); expect(cancelMock).not.toHaveBeenCalled(); expect(stopMock).toHaveBeenCalled(); diff --git a/spec/frontend/pipelines/pipelines_table_row_spec.js b/spec/frontend/pipelines/pipelines_table_row_spec.js index 660651547fc..68d46575081 100644 --- a/spec/frontend/pipelines/pipelines_table_row_spec.js +++ b/spec/frontend/pipelines/pipelines_table_row_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; import PipelinesTableRowComponent from '~/pipelines/components/pipelines_list/pipelines_table_row.vue'; import eventHub from '~/pipelines/event_hub'; @@ -9,7 +10,6 @@ describe('Pipelines Table Row', () => { mount(PipelinesTableRowComponent, { propsData: { pipeline, - autoDevopsHelpPath: 'foo', viewType: 'root', }, }); @@ -19,8 +19,6 @@ describe('Pipelines Table Row', () => { let pipelineWithoutAuthor; let pipelineWithoutCommit; - preloadFixtures(jsonFixtureName); - beforeEach(() => { const { pipelines } = getJSONFixture(jsonFixtureName); @@ -149,16 +147,22 @@ describe('Pipelines Table Row', () => { }); describe('stages column', () => { - beforeEach(() => { + const findAllMiniPipelineStages = () => + wrapper.findAll('.table-section:nth-child(5) [data-testid="mini-pipeline-graph-dropdown"]'); + + it('should render an icon for each stage', () => { wrapper = createWrapper(pipeline); + + expect(findAllMiniPipelineStages()).toHaveLength(pipeline.details.stages.length); }); - it('should render an icon for each stage', () => { - expect( - wrapper.findAll( - '.table-section:nth-child(4) [data-testid="mini-pipeline-graph-dropdown-toggle"]', - ).length, - ).toEqual(pipeline.details.stages.length); + it('should not render stages when stages are empty', () => { + const withoutStages = { ...pipeline }; + withoutStages.details = { ...withoutStages.details, stages: null }; + + wrapper = createWrapper(withoutStages); + + expect(findAllMiniPipelineStages()).toHaveLength(0); }); }); @@ -183,9 +187,16 @@ describe('Pipelines Table Row', () => { expect(wrapper.find('.js-pipelines-retry-button').attributes('title')).toMatch('Retry'); expect(wrapper.find('.js-pipelines-cancel-button').exists()).toBe(true); expect(wrapper.find('.js-pipelines-cancel-button').attributes('title')).toMatch('Cancel'); - const dropdownMenu = wrapper.find('.dropdown-menu'); + }); + + it('should render the manual actions', async () => { + const manualActions = wrapper.find('[data-testid="pipelines-manual-actions-dropdown"]'); + + // Click on the dropdown and wait for `lazy` dropdown items + manualActions.find('.dropdown-toggle').trigger('click'); + await waitForPromises(); - expect(dropdownMenu.text()).toContain(scheduledJobAction.name); + expect(manualActions.text()).toContain(scheduledJobAction.name); }); it('emits `retryPipeline` event when retry button is clicked and toggles loading', () => { diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js index fd73d507919..952bea81052 100644 --- a/spec/frontend/pipelines/pipelines_table_spec.js +++ b/spec/frontend/pipelines/pipelines_table_spec.js @@ -1,5 +1,18 @@ +import { GlTable } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; +import PipelineOperations from '~/pipelines/components/pipelines_list/pipeline_operations.vue'; +import PipelineTriggerer from '~/pipelines/components/pipelines_list/pipeline_triggerer.vue'; +import PipelineUrl from '~/pipelines/components/pipelines_list/pipeline_url.vue'; +import PipelinesStatusBadge from '~/pipelines/components/pipelines_list/pipelines_status_badge.vue'; import PipelinesTable from '~/pipelines/components/pipelines_list/pipelines_table.vue'; +import PipelinesTimeago from '~/pipelines/components/pipelines_list/time_ago.vue'; + +import eventHub from '~/pipelines/event_hub'; +import CommitComponent from '~/vue_shared/components/commit.vue'; + +jest.mock('~/pipelines/event_hub'); describe('Pipelines Table', () => { let pipeline; @@ -9,24 +22,52 @@ describe('Pipelines Table', () => { const defaultProps = { pipelines: [], - autoDevopsHelpPath: 'foo', viewType: 'root', }; - const createComponent = (props = defaultProps) => { - wrapper = mount(PipelinesTable, { - propsData: props, - }); + const createMockPipeline = () => { + const { pipelines } = getJSONFixture(jsonFixtureName); + return pipelines.find((p) => p.user !== null && p.commit !== null); }; - const findRows = () => wrapper.findAll('.commit.gl-responsive-table-row'); - preloadFixtures(jsonFixtureName); + const createComponent = (props = {}, flagState = false) => { + wrapper = extendedWrapper( + mount(PipelinesTable, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + glFeatures: { + newPipelinesTable: flagState, + }, + }, + }), + ); + }; - beforeEach(() => { - const { pipelines } = getJSONFixture(jsonFixtureName); - pipeline = pipelines.find((p) => p.user !== null && p.commit !== null); + const findRows = () => wrapper.findAll('.commit.gl-responsive-table-row'); + const findGlTable = () => wrapper.findComponent(GlTable); + const findStatusBadge = () => wrapper.findComponent(PipelinesStatusBadge); + const findPipelineInfo = () => wrapper.findComponent(PipelineUrl); + const findTriggerer = () => wrapper.findComponent(PipelineTriggerer); + const findCommit = () => wrapper.findComponent(CommitComponent); + const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph); + const findTimeAgo = () => wrapper.findComponent(PipelinesTimeago); + const findActions = () => wrapper.findComponent(PipelineOperations); + + const findLegacyTable = () => wrapper.findByTestId('legacy-ci-table'); + const findTableRows = () => wrapper.findAll('[data-testid="pipeline-table-row"]'); + const findStatusTh = () => wrapper.findByTestId('status-th'); + const findPipelineTh = () => wrapper.findByTestId('pipeline-th'); + const findTriggererTh = () => wrapper.findByTestId('triggerer-th'); + const findCommitTh = () => wrapper.findByTestId('commit-th'); + const findStagesTh = () => wrapper.findByTestId('stages-th'); + const findTimeAgoTh = () => wrapper.findByTestId('timeago-th'); + const findActionsTh = () => wrapper.findByTestId('actions-th'); - createComponent(); + beforeEach(() => { + pipeline = createMockPipeline(); }); afterEach(() => { @@ -34,33 +75,161 @@ describe('Pipelines Table', () => { wrapper = null; }); - describe('table', () => { - it('should render a table', () => { - expect(wrapper.classes()).toContain('ci-table'); + describe('table with feature flag off', () => { + describe('renders the table correctly', () => { + beforeEach(() => { + createComponent(); + }); + + it('should render a table', () => { + expect(wrapper.classes()).toContain('ci-table'); + }); + + it('should render table head with correct columns', () => { + expect(wrapper.find('.table-section.js-pipeline-status').text()).toEqual('Status'); + + expect(wrapper.find('.table-section.js-pipeline-info').text()).toEqual('Pipeline'); + + expect(wrapper.find('.table-section.js-pipeline-commit').text()).toEqual('Commit'); + + expect(wrapper.find('.table-section.js-pipeline-stages').text()).toEqual('Stages'); + }); }); - it('should render table head with correct columns', () => { - expect(wrapper.find('.table-section.js-pipeline-status').text()).toEqual('Status'); + describe('without data', () => { + it('should render an empty table', () => { + createComponent(); - expect(wrapper.find('.table-section.js-pipeline-info').text()).toEqual('Pipeline'); + expect(findRows()).toHaveLength(0); + }); + }); - expect(wrapper.find('.table-section.js-pipeline-commit').text()).toEqual('Commit'); + describe('with data', () => { + it('should render rows', () => { + createComponent({ pipelines: [pipeline], viewType: 'root' }); - expect(wrapper.find('.table-section.js-pipeline-stages').text()).toEqual('Stages'); + expect(findRows()).toHaveLength(1); + }); }); }); - describe('without data', () => { - it('should render an empty table', () => { - expect(findRows()).toHaveLength(0); + describe('table with feature flag on', () => { + beforeEach(() => { + createComponent({ pipelines: [pipeline], viewType: 'root' }, true); + }); + + it('displays new table', () => { + expect(findGlTable().exists()).toBe(true); + expect(findLegacyTable().exists()).toBe(false); + }); + + it('should render table head with correct columns', () => { + expect(findStatusTh().text()).toBe('Status'); + expect(findPipelineTh().text()).toBe('Pipeline'); + expect(findTriggererTh().text()).toBe('Triggerer'); + expect(findCommitTh().text()).toBe('Commit'); + expect(findStagesTh().text()).toBe('Stages'); + expect(findTimeAgoTh().text()).toBe('Duration'); + expect(findActionsTh().text()).toBe('Actions'); + }); + + it('should display a table row', () => { + expect(findTableRows()).toHaveLength(1); }); - }); - describe('with data', () => { - it('should render rows', () => { - createComponent({ pipelines: [pipeline], autoDevopsHelpPath: 'foo', viewType: 'root' }); + describe('status cell', () => { + it('should render a status badge', () => { + expect(findStatusBadge().exists()).toBe(true); + }); + + it('should render status badge with correct path', () => { + expect(findStatusBadge().attributes('href')).toBe(pipeline.path); + }); + }); + + describe('pipeline cell', () => { + it('should render pipeline information', () => { + expect(findPipelineInfo().exists()).toBe(true); + }); + + it('should display the pipeline id', () => { + expect(findPipelineInfo().text()).toContain(`#${pipeline.id}`); + }); + }); + + describe('triggerer cell', () => { + it('should render the pipeline triggerer', () => { + expect(findTriggerer().exists()).toBe(true); + }); + }); + + describe('commit cell', () => { + it('should render commit information', () => { + expect(findCommit().exists()).toBe(true); + }); + + it('should display and link to commit', () => { + expect(findCommit().text()).toContain(pipeline.commit.short_id); + expect(findCommit().props('commitUrl')).toBe(pipeline.commit.commit_path); + }); + + it('should display the commit author', () => { + expect(findCommit().props('author')).toEqual(pipeline.commit.author); + }); + }); + + describe('stages cell', () => { + it('should render a pipeline mini graph', () => { + expect(findPipelineMiniGraph().exists()).toBe(true); + }); + + it('should render the right number of stages', () => { + const stagesLength = pipeline.details.stages.length; + expect( + findPipelineMiniGraph().findAll('[data-testid="mini-pipeline-graph-dropdown"]'), + ).toHaveLength(stagesLength); + }); + + describe('when pipeline does not have stages', () => { + beforeEach(() => { + pipeline = createMockPipeline(); + pipeline.details.stages = null; + + createComponent({ pipelines: [pipeline] }, true); + }); + + it('stages are not rendered', () => { + expect(findPipelineMiniGraph().exists()).toBe(false); + }); + }); + + it('should not update dropdown', () => { + expect(findPipelineMiniGraph().props('updateDropdown')).toBe(false); + }); + + it('when update graph dropdown is set, should update graph dropdown', () => { + createComponent({ pipelines: [pipeline], updateGraphDropdown: true }, true); + + expect(findPipelineMiniGraph().props('updateDropdown')).toBe(true); + }); + + it('when action request is complete, should refresh table', () => { + findPipelineMiniGraph().vm.$emit('pipelineActionRequestComplete'); + + expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable'); + }); + }); + + describe('duration cell', () => { + it('should render duration information', () => { + expect(findTimeAgo().exists()).toBe(true); + }); + }); - expect(findRows()).toHaveLength(1); + describe('operations cell', () => { + it('should render pipeline operations', () => { + expect(findActions().exists()).toBe(true); + }); }); }); }); diff --git a/spec/frontend/pipelines/stage_spec.js b/spec/frontend/pipelines/stage_spec.js deleted file mode 100644 index 87b43558252..00000000000 --- a/spec/frontend/pipelines/stage_spec.js +++ /dev/null @@ -1,297 +0,0 @@ -import 'bootstrap/js/dist/dropdown'; -import { GlDropdown } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import $ from 'jquery'; -import axios from '~/lib/utils/axios_utils'; -import StageComponent from '~/pipelines/components/pipelines_list/stage.vue'; -import eventHub from '~/pipelines/event_hub'; -import { stageReply } from './mock_data'; - -describe('Pipelines stage component', () => { - let wrapper; - let mock; - let glFeatures; - - const defaultProps = { - stage: { - status: { - group: 'success', - icon: 'status_success', - title: 'success', - }, - dropdown_path: 'path.json', - }, - updateDropdown: false, - }; - - const createComponent = (props = {}) => { - wrapper = mount(StageComponent, { - attachTo: document.body, - propsData: { - ...defaultProps, - ...props, - }, - provide: { - glFeatures, - }, - }); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - jest.spyOn(eventHub, '$emit'); - glFeatures = {}; - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - - eventHub.$emit.mockRestore(); - mock.restore(); - }); - - describe('when ci_mini_pipeline_gl_dropdown feature flag is disabled', () => { - const isDropdownOpen = () => wrapper.classes('show'); - - describe('default', () => { - beforeEach(() => { - createComponent(); - }); - - it('should render a dropdown with the status icon', () => { - expect(wrapper.attributes('class')).toEqual('dropdown'); - expect(wrapper.find('svg').exists()).toBe(true); - expect(wrapper.find('button').attributes('data-toggle')).toEqual('dropdown'); - }); - }); - - describe('with successful request', () => { - beforeEach(() => { - mock.onGet('path.json').reply(200, stageReply); - createComponent(); - }); - - it('should render the received data and emit `clickedDropdown` event', async () => { - wrapper.find('button').trigger('click'); - - await axios.waitForAll(); - expect(wrapper.find('.js-builds-dropdown-container ul').text()).toContain( - stageReply.latest_statuses[0].name, - ); - - expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); - }); - }); - - it('when request fails should close the dropdown', async () => { - mock.onGet('path.json').reply(500); - createComponent(); - wrapper.find({ ref: 'dropdown' }).trigger('click'); - - expect(isDropdownOpen()).toBe(true); - - wrapper.find('button').trigger('click'); - await axios.waitForAll(); - - expect(isDropdownOpen()).toBe(false); - }); - - describe('update endpoint correctly', () => { - beforeEach(() => { - const copyStage = { ...stageReply }; - copyStage.latest_statuses[0].name = 'this is the updated content'; - mock.onGet('bar.json').reply(200, copyStage); - createComponent({ - stage: { - status: { - group: 'running', - icon: 'status_running', - title: 'running', - }, - dropdown_path: 'bar.json', - }, - }); - return axios.waitForAll(); - }); - - it('should update the stage to request the new endpoint provided', async () => { - wrapper.find('button').trigger('click'); - await axios.waitForAll(); - - expect(wrapper.find('.js-builds-dropdown-container ul').text()).toContain( - 'this is the updated content', - ); - }); - }); - - describe('pipelineActionRequestComplete', () => { - beforeEach(() => { - mock.onGet('path.json').reply(200, stageReply); - mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200); - }); - - const clickCiAction = async () => { - wrapper.find('button').trigger('click'); - await axios.waitForAll(); - - wrapper.find('.js-ci-action').trigger('click'); - await axios.waitForAll(); - }; - - describe('within pipeline table', () => { - it('emits `refreshPipelinesTable` event when `pipelineActionRequestComplete` is triggered', async () => { - createComponent({ type: 'PIPELINES_TABLE' }); - - await clickCiAction(); - - expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable'); - }); - }); - - describe('in MR widget', () => { - beforeEach(() => { - jest.spyOn($.fn, 'dropdown'); - }); - - it('closes the dropdown when `pipelineActionRequestComplete` is triggered', async () => { - createComponent(); - - await clickCiAction(); - - expect($.fn.dropdown).toHaveBeenCalledWith('toggle'); - }); - }); - }); - }); - - describe('when ci_mini_pipeline_gl_dropdown feature flag is enabled', () => { - const findDropdown = () => wrapper.find(GlDropdown); - const findDropdownToggle = () => wrapper.find('button.gl-dropdown-toggle'); - const findDropdownMenu = () => - wrapper.find('[data-testid="mini-pipeline-graph-dropdown-menu-list"]'); - const findCiActionBtn = () => wrapper.find('.js-ci-action'); - - const openGlDropdown = () => { - findDropdownToggle().trigger('click'); - return new Promise((resolve) => { - wrapper.vm.$root.$on('bv::dropdown::show', resolve); - }); - }; - - beforeEach(() => { - glFeatures = { ciMiniPipelineGlDropdown: true }; - }); - - describe('default', () => { - beforeEach(() => { - createComponent(); - }); - - it('should render a dropdown with the status icon', () => { - expect(findDropdown().exists()).toBe(true); - expect(findDropdownToggle().classes('gl-dropdown-toggle')).toEqual(true); - expect(wrapper.find('[data-testid="status_success_borderless-icon"]').exists()).toBe(true); - }); - }); - - describe('with successful request', () => { - beforeEach(() => { - mock.onGet('path.json').reply(200, stageReply); - createComponent(); - }); - - it('should render the received data and emit `clickedDropdown` event', async () => { - await openGlDropdown(); - await axios.waitForAll(); - - expect(findDropdownMenu().text()).toContain(stageReply.latest_statuses[0].name); - expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); - }); - }); - - it('when request fails should close the dropdown', async () => { - mock.onGet('path.json').reply(500); - - createComponent(); - - await openGlDropdown(); - await axios.waitForAll(); - - expect(findDropdown().classes('show')).toBe(false); - }); - - describe('update endpoint correctly', () => { - beforeEach(async () => { - const copyStage = { ...stageReply }; - copyStage.latest_statuses[0].name = 'this is the updated content'; - mock.onGet('bar.json').reply(200, copyStage); - createComponent({ - stage: { - status: { - group: 'running', - icon: 'status_running', - title: 'running', - }, - dropdown_path: 'bar.json', - }, - }); - await axios.waitForAll(); - }); - - it('should update the stage to request the new endpoint provided', async () => { - await openGlDropdown(); - await axios.waitForAll(); - - expect(findDropdownMenu().text()).toContain('this is the updated content'); - }); - }); - - describe('pipelineActionRequestComplete', () => { - beforeEach(() => { - mock.onGet('path.json').reply(200, stageReply); - mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200); - }); - - const clickCiAction = async () => { - await openGlDropdown(); - await axios.waitForAll(); - - findCiActionBtn().trigger('click'); - await axios.waitForAll(); - }; - - describe('within pipeline table', () => { - beforeEach(() => { - createComponent({ type: 'PIPELINES_TABLE' }); - }); - - it('emits `refreshPipelinesTable` event when `pipelineActionRequestComplete` is triggered', async () => { - await clickCiAction(); - - expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable'); - }); - }); - - describe('in MR widget', () => { - beforeEach(() => { - jest.spyOn($.fn, 'dropdown'); - createComponent(); - }); - - it('closes the dropdown when `pipelineActionRequestComplete` is triggered', async () => { - const hidden = jest.fn(); - - wrapper.vm.$root.$on('bv::dropdown::hide', hidden); - - expect(hidden).toHaveBeenCalledTimes(0); - - await clickCiAction(); - - expect(hidden).toHaveBeenCalledTimes(1); - }); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/time_ago_spec.js b/spec/frontend/pipelines/time_ago_spec.js index 55a19ef5165..93aeb049434 100644 --- a/spec/frontend/pipelines/time_ago_spec.js +++ b/spec/frontend/pipelines/time_ago_spec.js @@ -8,7 +8,11 @@ describe('Timeago component', () => { const createComponent = (props = {}) => { wrapper = shallowMount(TimeAgo, { propsData: { - ...props, + pipeline: { + details: { + ...props, + }, + }, }, data() { return { @@ -25,10 +29,11 @@ describe('Timeago component', () => { const duration = () => wrapper.find('.duration'); const finishedAt = () => wrapper.find('.finished-at'); + const findInProgress = () => wrapper.find('[data-testid="pipeline-in-progress"]'); describe('with duration', () => { beforeEach(() => { - createComponent({ duration: 10, finishedTime: '' }); + createComponent({ duration: 10, finished_at: '' }); }); it('should render duration and timer svg', () => { @@ -41,7 +46,7 @@ describe('Timeago component', () => { describe('without duration', () => { beforeEach(() => { - createComponent({ duration: 0, finishedTime: '' }); + createComponent({ duration: 0, finished_at: '' }); }); it('should not render duration and timer svg', () => { @@ -51,7 +56,7 @@ describe('Timeago component', () => { describe('with finishedTime', () => { beforeEach(() => { - createComponent({ duration: 0, finishedTime: '2017-04-26T12:40:23.277Z' }); + createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }); }); it('should render time and calendar icon', () => { @@ -66,11 +71,28 @@ describe('Timeago component', () => { describe('without finishedTime', () => { beforeEach(() => { - createComponent({ duration: 0, finishedTime: '' }); + createComponent({ duration: 0, finished_at: '' }); }); it('should not render time and calendar icon', () => { expect(finishedAt().exists()).toBe(false); }); }); + + describe('in progress', () => { + it.each` + durationTime | finishedAtTime | shouldShow + ${10} | ${'2017-04-26T12:40:23.277Z'} | ${false} + ${10} | ${''} | ${false} + ${0} | ${'2017-04-26T12:40:23.277Z'} | ${false} + ${0} | ${''} | ${true} + `( + 'progress state shown: $shouldShow when pipeline duration is $durationTime and finished_at is $finishedAtTime', + ({ durationTime, finishedAtTime, shouldShow }) => { + createComponent({ duration: durationTime, finished_at: finishedAtTime }); + + expect(findInProgress().exists()).toBe(shouldShow); + }, + ); + }); }); diff --git a/spec/frontend/pipelines_spec.js b/spec/frontend/pipelines_spec.js index 6d4d634c575..add91fbcc23 100644 --- a/spec/frontend/pipelines_spec.js +++ b/spec/frontend/pipelines_spec.js @@ -1,8 +1,6 @@ import Pipelines from '~/pipelines'; describe('Pipelines', () => { - preloadFixtures('static/pipeline_graph.html'); - beforeEach(() => { loadFixtures('static/pipeline_graph.html'); }); diff --git a/spec/frontend/profile/account/components/update_username_spec.js b/spec/frontend/profile/account/components/update_username_spec.js index 8295d1d43cf..a3d7b63373c 100644 --- a/spec/frontend/profile/account/components/update_username_spec.js +++ b/spec/frontend/profile/account/components/update_username_spec.js @@ -2,10 +2,13 @@ import { GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import UpdateUsername from '~/profile/account/components/update_username.vue'; +jest.mock('~/flash'); + describe('UpdateUsername component', () => { const rootUrl = TEST_HOST; const actionUrl = `${TEST_HOST}/update/username`; @@ -105,7 +108,8 @@ describe('UpdateUsername component', () => { axiosMock.onPut(actionUrl).replyOnce(() => { expect(input.attributes('disabled')).toBe('disabled'); - expect(openModalBtn.props('disabled')).toBe(true); + expect(openModalBtn.props('disabled')).toBe(false); + expect(openModalBtn.props('loading')).toBe(true); return [200, { message: 'Username changed' }]; }); @@ -115,6 +119,7 @@ describe('UpdateUsername component', () => { expect(input.attributes('disabled')).toBe(undefined); expect(openModalBtn.props('disabled')).toBe(true); + expect(openModalBtn.props('loading')).toBe(false); }); it('does not set the username after a erroneous update', async () => { @@ -122,7 +127,8 @@ describe('UpdateUsername component', () => { axiosMock.onPut(actionUrl).replyOnce(() => { expect(input.attributes('disabled')).toBe('disabled'); - expect(openModalBtn.props('disabled')).toBe(true); + expect(openModalBtn.props('disabled')).toBe(false); + expect(openModalBtn.props('loading')).toBe(true); return [400, { message: 'Invalid username' }]; }); @@ -130,6 +136,29 @@ describe('UpdateUsername component', () => { await expect(wrapper.vm.onConfirm()).rejects.toThrow(); expect(input.attributes('disabled')).toBe(undefined); expect(openModalBtn.props('disabled')).toBe(false); + expect(openModalBtn.props('loading')).toBe(false); + }); + + it('shows an error message if the error response has a `message` property', async () => { + axiosMock.onPut(actionUrl).replyOnce(() => { + return [400, { message: 'Invalid username' }]; + }); + + await expect(wrapper.vm.onConfirm()).rejects.toThrow(); + + expect(createFlash).toBeCalledWith('Invalid username'); + }); + + it("shows a fallback error message if the error response doesn't have a `message` property", async () => { + axiosMock.onPut(actionUrl).replyOnce(() => { + return [400]; + }); + + await expect(wrapper.vm.onConfirm()).rejects.toThrow(); + + expect(createFlash).toBeCalledWith( + 'An error occurred while updating your username, please try again.', + ); }); }); }); diff --git a/spec/frontend/profile/preferences/components/profile_preferences_spec.js b/spec/frontend/profile/preferences/components/profile_preferences_spec.js index 82c41178410..9e6f5594d26 100644 --- a/spec/frontend/profile/preferences/components/profile_preferences_spec.js +++ b/spec/frontend/profile/preferences/components/profile_preferences_spec.js @@ -1,10 +1,19 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import IntegrationView from '~/profile/preferences/components/integration_view.vue'; import ProfilePreferences from '~/profile/preferences/components/profile_preferences.vue'; import { i18n } from '~/profile/preferences/constants'; -import { integrationViews, userFields, bodyClasses } from '../mock_data'; +import { + integrationViews, + userFields, + bodyClasses, + themes, + lightModeThemeId1, + darkModeThemeId, + lightModeThemeId2, +} from '../mock_data'; const expectedUrl = '/foo'; @@ -14,7 +23,7 @@ describe('ProfilePreferences component', () => { integrationViews: [], userFields, bodyClasses, - themes: [{ id: 1, css_class: 'foo' }], + themes, profilePreferencesPath: '/update-profile', formEl: document.createElement('form'), }; @@ -49,6 +58,30 @@ describe('ProfilePreferences component', () => { return document.querySelector('.flash-container .flash-text'); } + function createThemeInput(themeId = lightModeThemeId1) { + const input = document.createElement('input'); + input.setAttribute('name', 'user[theme_id]'); + input.setAttribute('type', 'radio'); + input.setAttribute('value', themeId.toString()); + input.setAttribute('checked', 'checked'); + return input; + } + + function createForm(themeInput = createThemeInput()) { + const form = document.createElement('form'); + form.setAttribute('url', expectedUrl); + form.setAttribute('method', 'put'); + form.appendChild(themeInput); + return form; + } + + function setupBody() { + const div = document.createElement('div'); + div.classList.add('container-fluid'); + document.body.appendChild(div); + document.body.classList.add('content-wrapper'); + } + beforeEach(() => { setFixtures('<div class="flash-container"></div>'); }); @@ -84,30 +117,15 @@ describe('ProfilePreferences component', () => { let form; beforeEach(() => { - const div = document.createElement('div'); - div.classList.add('container-fluid'); - document.body.appendChild(div); - document.body.classList.add('content-wrapper'); - - form = document.createElement('form'); - form.setAttribute('url', expectedUrl); - form.setAttribute('method', 'put'); - - const input = document.createElement('input'); - input.setAttribute('name', 'user[theme_id]'); - input.setAttribute('type', 'radio'); - input.setAttribute('value', '1'); - input.setAttribute('checked', 'checked'); - form.appendChild(input); - + setupBody(); + form = createForm(); wrapper = createComponent({ provide: { formEl: form }, attachTo: document.body }); - const beforeSendEvent = new CustomEvent('ajax:beforeSend'); form.dispatchEvent(beforeSendEvent); }); it('disables the submit button', async () => { - await wrapper.vm.$nextTick(); + await nextTick(); const button = findSubmitButton(); expect(button.props('disabled')).toBe(true); }); @@ -116,7 +134,7 @@ describe('ProfilePreferences component', () => { const successEvent = new CustomEvent('ajax:success'); form.dispatchEvent(successEvent); - await wrapper.vm.$nextTick(); + await nextTick(); const button = findSubmitButton(); expect(button.props('disabled')).toBe(false); }); @@ -125,7 +143,7 @@ describe('ProfilePreferences component', () => { const errorEvent = new CustomEvent('ajax:error'); form.dispatchEvent(errorEvent); - await wrapper.vm.$nextTick(); + await nextTick(); const button = findSubmitButton(); expect(button.props('disabled')).toBe(false); }); @@ -160,4 +178,89 @@ describe('ProfilePreferences component', () => { expect(findFlashError().innerText.trim()).toEqual(message); }); }); + + describe('theme changes', () => { + const { location } = window; + + let themeInput; + let form; + + function setupWrapper() { + wrapper = createComponent({ provide: { formEl: form }, attachTo: document.body }); + } + + function selectThemeId(themeId) { + themeInput.setAttribute('value', themeId.toString()); + } + + function dispatchBeforeSendEvent() { + const beforeSendEvent = new CustomEvent('ajax:beforeSend'); + form.dispatchEvent(beforeSendEvent); + } + + function dispatchSuccessEvent() { + const successEvent = new CustomEvent('ajax:success'); + form.dispatchEvent(successEvent); + } + + beforeAll(() => { + delete window.location; + window.location = { + ...location, + reload: jest.fn(), + }; + }); + + afterAll(() => { + window.location = location; + }); + + beforeEach(() => { + setupBody(); + themeInput = createThemeInput(); + form = createForm(themeInput); + }); + + it('reloads the page when switching from light to dark mode', async () => { + selectThemeId(lightModeThemeId1); + setupWrapper(); + + selectThemeId(darkModeThemeId); + dispatchBeforeSendEvent(); + await nextTick(); + + dispatchSuccessEvent(); + await nextTick(); + + expect(window.location.reload).toHaveBeenCalledTimes(1); + }); + + it('reloads the page when switching from dark to light mode', async () => { + selectThemeId(darkModeThemeId); + setupWrapper(); + + selectThemeId(lightModeThemeId1); + dispatchBeforeSendEvent(); + await nextTick(); + + dispatchSuccessEvent(); + await nextTick(); + + expect(window.location.reload).toHaveBeenCalledTimes(1); + }); + + it('does not reload the page when switching between light mode themes', async () => { + selectThemeId(lightModeThemeId1); + setupWrapper(); + + selectThemeId(lightModeThemeId2); + dispatchBeforeSendEvent(); + await nextTick(); + + dispatchSuccessEvent(); + await nextTick(); + + expect(window.location.reload).not.toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/profile/preferences/mock_data.js b/spec/frontend/profile/preferences/mock_data.js index ce33fc79a39..91cfdfadc78 100644 --- a/spec/frontend/profile/preferences/mock_data.js +++ b/spec/frontend/profile/preferences/mock_data.js @@ -18,3 +18,15 @@ export const userFields = { }; export const bodyClasses = 'ui-light-indigo ui-light gl-dark'; + +export const themes = [ + { id: 1, css_class: 'foo' }, + { id: 2, css_class: 'bar' }, + { id: 3, css_class: 'gl-dark' }, +]; + +export const lightModeThemeId1 = 1; + +export const lightModeThemeId2 = 2; + +export const darkModeThemeId = 3; diff --git a/spec/frontend/project_select_combo_button_spec.js b/spec/frontend/project_select_combo_button_spec.js index c47db71b4ac..5cdc3d174a1 100644 --- a/spec/frontend/project_select_combo_button_spec.js +++ b/spec/frontend/project_select_combo_button_spec.js @@ -10,8 +10,6 @@ describe('Project Select Combo Button', () => { testContext = {}; }); - preloadFixtures(fixturePath); - beforeEach(() => { testContext.defaults = { label: 'Select project to create issue', diff --git a/spec/frontend/projects/commit/components/form_modal_spec.js b/spec/frontend/projects/commit/components/form_modal_spec.js index 1569f5b4bbe..708644cb7ee 100644 --- a/spec/frontend/projects/commit/components/form_modal_spec.js +++ b/spec/frontend/projects/commit/components/form_modal_spec.js @@ -7,6 +7,7 @@ import axios from '~/lib/utils/axios_utils'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import BranchesDropdown from '~/projects/commit/components/branches_dropdown.vue'; import CommitFormModal from '~/projects/commit/components/form_modal.vue'; +import ProjectsDropdown from '~/projects/commit/components/projects_dropdown.vue'; import eventHub from '~/projects/commit/event_hub'; import createStore from '~/projects/commit/store'; import mockData from '../mock_data'; @@ -20,7 +21,10 @@ describe('CommitFormModal', () => { store = createStore({ ...mockData.mockModal, ...state }); wrapper = extendedWrapper( method(CommitFormModal, { - provide, + provide: { + ...provide, + glFeatures: { pickIntoProject: true }, + }, propsData: { ...mockData.modalPropsData }, store, attrs: { @@ -33,7 +37,9 @@ describe('CommitFormModal', () => { const findModal = () => wrapper.findComponent(GlModal); const findStartBranch = () => wrapper.find('#start_branch'); - const findDropdown = () => wrapper.findComponent(BranchesDropdown); + const findTargetProject = () => wrapper.find('#target_project_id'); + const findBranchesDropdown = () => wrapper.findComponent(BranchesDropdown); + const findProjectsDropdown = () => wrapper.findComponent(ProjectsDropdown); const findForm = () => findModal().findComponent(GlForm); const findCheckBox = () => findForm().findComponent(GlFormCheckbox); const findPrependedText = () => wrapper.findByTestId('prepended-text'); @@ -146,11 +152,19 @@ describe('CommitFormModal', () => { }); it('Changes the start_branch input value', async () => { - findDropdown().vm.$emit('selectBranch', '_changed_branch_value_'); + findBranchesDropdown().vm.$emit('selectBranch', '_changed_branch_value_'); await wrapper.vm.$nextTick(); expect(findStartBranch().attributes('value')).toBe('_changed_branch_value_'); }); + + it('Changes the target_project_id input value', async () => { + findProjectsDropdown().vm.$emit('selectProject', '_changed_project_value_'); + + await wrapper.vm.$nextTick(); + + expect(findTargetProject().attributes('value')).toBe('_changed_project_value_'); + }); }); }); diff --git a/spec/frontend/projects/commit/components/projects_dropdown_spec.js b/spec/frontend/projects/commit/components/projects_dropdown_spec.js new file mode 100644 index 00000000000..bb20918e0cd --- /dev/null +++ b/spec/frontend/projects/commit/components/projects_dropdown_spec.js @@ -0,0 +1,124 @@ +import { GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import ProjectsDropdown from '~/projects/commit/components/projects_dropdown.vue'; + +Vue.use(Vuex); + +describe('ProjectsDropdown', () => { + let wrapper; + let store; + const spyFetchProjects = jest.fn(); + const projectsMockData = [ + { id: '1', name: '_project_1_', refsUrl: '_project_1_/refs' }, + { id: '2', name: '_project_2_', refsUrl: '_project_2_/refs' }, + { id: '3', name: '_project_3_', refsUrl: '_project_3_/refs' }, + ]; + + const createComponent = (term, state = {}) => { + store = new Vuex.Store({ + getters: { + sortedProjects: () => projectsMockData, + }, + state, + }); + + wrapper = extendedWrapper( + shallowMount(ProjectsDropdown, { + store, + propsData: { + value: term, + }, + }), + ); + }; + + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType); + const findDropdownItemByIndex = (index) => wrapper.findAllComponents(GlDropdownItem).at(index); + const findNoResults = () => wrapper.findByTestId('empty-result-message'); + + afterEach(() => { + wrapper.destroy(); + spyFetchProjects.mockReset(); + }); + + describe('No projects found', () => { + beforeEach(() => { + createComponent('_non_existent_project_'); + }); + + it('renders empty results message', () => { + expect(findNoResults().text()).toBe('No matching results'); + }); + + it('shows GlSearchBoxByType with default attributes', () => { + expect(findSearchBoxByType().exists()).toBe(true); + expect(findSearchBoxByType().vm.$attrs).toMatchObject({ + placeholder: 'Search projects', + }); + }); + }); + + describe('Search term is empty', () => { + beforeEach(() => { + createComponent(''); + }); + + it('renders all projects when search term is empty', () => { + expect(findAllDropdownItems()).toHaveLength(3); + expect(findDropdownItemByIndex(0).text()).toBe('_project_1_'); + expect(findDropdownItemByIndex(1).text()).toBe('_project_2_'); + expect(findDropdownItemByIndex(2).text()).toBe('_project_3_'); + }); + + it('should not be selected on the inactive project', () => { + expect(wrapper.vm.isSelected('_project_1_')).toBe(false); + }); + }); + + describe('Projects found', () => { + beforeEach(() => { + createComponent('_project_1_', { targetProjectId: '1' }); + }); + + it('renders only the project searched for', () => { + expect(findAllDropdownItems()).toHaveLength(1); + expect(findDropdownItemByIndex(0).text()).toBe('_project_1_'); + }); + + it('should not display empty results message', () => { + expect(findNoResults().exists()).toBe(false); + }); + + it('should signify this project is selected', () => { + expect(findDropdownItemByIndex(0).props('isChecked')).toBe(true); + }); + + it('should signify the project is not selected', () => { + expect(wrapper.vm.isSelected('_not_selected_project_')).toBe(false); + }); + + describe('Custom events', () => { + it('should emit selectProject if a project is clicked', () => { + findDropdownItemByIndex(0).vm.$emit('click'); + + expect(wrapper.emitted('selectProject')).toEqual([['1']]); + expect(wrapper.vm.filterTerm).toBe('_project_1_'); + }); + }); + }); + + describe('Case insensitive for search term', () => { + beforeEach(() => { + createComponent('_PrOjEcT_1_'); + }); + + it('renders only the project searched for', () => { + expect(findAllDropdownItems()).toHaveLength(1); + expect(findDropdownItemByIndex(0).text()).toBe('_project_1_'); + }); + }); +}); diff --git a/spec/frontend/projects/commit/mock_data.js b/spec/frontend/projects/commit/mock_data.js index 2b3b5a14c98..e4dcb24c4c0 100644 --- a/spec/frontend/projects/commit/mock_data.js +++ b/spec/frontend/projects/commit/mock_data.js @@ -24,4 +24,5 @@ export default { openModal: '_open_modal_', }, mockBranches: ['_branch_1', '_abc_', '_master_'], + mockProjects: ['_project_1', '_abc_', '_project_'], }; diff --git a/spec/frontend/projects/commit/store/actions_spec.js b/spec/frontend/projects/commit/store/actions_spec.js index 458372229cf..305257c9ca5 100644 --- a/spec/frontend/projects/commit/store/actions_spec.js +++ b/spec/frontend/projects/commit/store/actions_spec.js @@ -47,7 +47,7 @@ describe('Commit form modal store actions', () => { it('dispatch correct actions on fetchBranches', (done) => { jest .spyOn(axios, 'get') - .mockImplementation(() => Promise.resolve({ data: mockData.mockBranches })); + .mockImplementation(() => Promise.resolve({ data: { Branches: mockData.mockBranches } })); testAction( actions.fetchBranches, @@ -108,4 +108,43 @@ describe('Commit form modal store actions', () => { ]); }); }); + + describe('setBranchesEndpoint', () => { + it('commits SET_BRANCHES_ENDPOINT mutation', () => { + const endpoint = 'some/endpoint'; + + testAction(actions.setBranchesEndpoint, endpoint, {}, [ + { + type: types.SET_BRANCHES_ENDPOINT, + payload: endpoint, + }, + ]); + }); + }); + + describe('setSelectedProject', () => { + const id = 1; + + it('commits SET_SELECTED_PROJECT mutation', () => { + testAction( + actions.setSelectedProject, + id, + {}, + [ + { + type: types.SET_SELECTED_PROJECT, + payload: id, + }, + ], + [ + { + type: 'setBranchesEndpoint', + }, + { + type: 'fetchBranches', + }, + ], + ); + }); + }); }); diff --git a/spec/frontend/projects/commit/store/getters_spec.js b/spec/frontend/projects/commit/store/getters_spec.js index bd0cb356854..38c45af7aa0 100644 --- a/spec/frontend/projects/commit/store/getters_spec.js +++ b/spec/frontend/projects/commit/store/getters_spec.js @@ -18,4 +18,21 @@ describe('Commit form modal getters', () => { expect(getters.joinedBranches(state)).toEqual(branches.slice(1)); }); }); + + describe('sortedProjects', () => { + it('should sort projects with variable branches', () => { + const state = { + projects: mockData.mockProjects, + }; + + expect(getters.sortedProjects(state)).toEqual(mockData.mockProjects.sort()); + }); + + it('should provide a uniq list of projects', () => { + const projects = ['_project_', '_project_', '_some_other_project']; + const state = { projects }; + + expect(getters.sortedProjects(state)).toEqual(projects.slice(1)); + }); + }); }); diff --git a/spec/frontend/projects/commit/store/mutations_spec.js b/spec/frontend/projects/commit/store/mutations_spec.js index 2ea50e71772..8989e769772 100644 --- a/spec/frontend/projects/commit/store/mutations_spec.js +++ b/spec/frontend/projects/commit/store/mutations_spec.js @@ -35,6 +35,16 @@ describe('Commit form modal mutations', () => { }); }); + describe('SET_BRANCHES_ENDPOINT', () => { + it('should set branchesEndpoint', () => { + stateCopy = { branchesEndpoint: 'endpoint/1' }; + + mutations[types.SET_BRANCHES_ENDPOINT](stateCopy, 'endpoint/2'); + + expect(stateCopy.branchesEndpoint).toBe('endpoint/2'); + }); + }); + describe('SET_BRANCH', () => { it('should set branch', () => { stateCopy = { branch: '_master_' }; @@ -54,4 +64,14 @@ describe('Commit form modal mutations', () => { expect(stateCopy.selectedBranch).toBe('_changed_branch_'); }); }); + + describe('SET_SELECTED_PROJECT', () => { + it('should set targetProjectId', () => { + stateCopy = { targetProjectId: '_project_1_' }; + + mutations[types.SET_SELECTED_PROJECT](stateCopy, '_project_2_'); + + expect(stateCopy.targetProjectId).toBe('_project_2_'); + }); + }); }); diff --git a/spec/frontend/projects/compare/components/app_legacy_spec.js b/spec/frontend/projects/compare/components/app_legacy_spec.js new file mode 100644 index 00000000000..4c7f0d5cccc --- /dev/null +++ b/spec/frontend/projects/compare/components/app_legacy_spec.js @@ -0,0 +1,116 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import CompareApp from '~/projects/compare/components/app_legacy.vue'; +import RevisionDropdown from '~/projects/compare/components/revision_dropdown_legacy.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +const projectCompareIndexPath = 'some/path'; +const refsProjectPath = 'some/refs/path'; +const paramsFrom = 'master'; +const paramsTo = 'master'; + +describe('CompareApp component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(CompareApp, { + propsData: { + projectCompareIndexPath, + refsProjectPath, + paramsFrom, + paramsTo, + projectMergeRequestPath: '', + createMrPath: '', + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + beforeEach(() => { + createComponent(); + }); + + it('renders component with prop', () => { + expect(wrapper.props()).toEqual( + expect.objectContaining({ + projectCompareIndexPath, + refsProjectPath, + paramsFrom, + paramsTo, + }), + ); + }); + + it('contains the correct form attributes', () => { + expect(wrapper.attributes('action')).toBe(projectCompareIndexPath); + expect(wrapper.attributes('method')).toBe('POST'); + }); + + it('has input with csrf token', () => { + expect(wrapper.find('input[name="authenticity_token"]').attributes('value')).toBe( + 'mock-csrf-token', + ); + }); + + it('has ellipsis', () => { + expect(wrapper.find('[data-testid="ellipsis"]').exists()).toBe(true); + }); + + it('render Source and Target BranchDropdown components', () => { + const branchDropdowns = wrapper.findAll(RevisionDropdown); + + expect(branchDropdowns.length).toBe(2); + expect(branchDropdowns.at(0).props('revisionText')).toBe('Source'); + expect(branchDropdowns.at(1).props('revisionText')).toBe('Target'); + }); + + describe('compare button', () => { + const findCompareButton = () => wrapper.find(GlButton); + + it('renders button', () => { + expect(findCompareButton().exists()).toBe(true); + }); + + it('submits form', () => { + findCompareButton().vm.$emit('click'); + expect(wrapper.find('form').element.submit).toHaveBeenCalled(); + }); + + it('has compare text', () => { + expect(findCompareButton().text()).toBe('Compare'); + }); + }); + + describe('merge request buttons', () => { + const findProjectMrButton = () => wrapper.find('[data-testid="projectMrButton"]'); + const findCreateMrButton = () => wrapper.find('[data-testid="createMrButton"]'); + + it('does not have merge request buttons', () => { + createComponent(); + expect(findProjectMrButton().exists()).toBe(false); + expect(findCreateMrButton().exists()).toBe(false); + }); + + it('has "View open merge request" button', () => { + createComponent({ + projectMergeRequestPath: 'some/project/merge/request/path', + }); + expect(findProjectMrButton().exists()).toBe(true); + expect(findCreateMrButton().exists()).toBe(false); + }); + + it('has "Create merge request" button', () => { + createComponent({ + createMrPath: 'some/create/create/mr/path', + }); + expect(findProjectMrButton().exists()).toBe(false); + expect(findCreateMrButton().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/projects/compare/components/app_spec.js b/spec/frontend/projects/compare/components/app_spec.js index d28a30e93b1..6de06e4373c 100644 --- a/spec/frontend/projects/compare/components/app_spec.js +++ b/spec/frontend/projects/compare/components/app_spec.js @@ -1,7 +1,7 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import CompareApp from '~/projects/compare/components/app.vue'; -import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vue'; +import RevisionCard from '~/projects/compare/components/revision_card.vue'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); @@ -63,11 +63,11 @@ describe('CompareApp component', () => { }); it('render Source and Target BranchDropdown components', () => { - const branchDropdowns = wrapper.findAll(RevisionDropdown); + const revisionCards = wrapper.findAll(RevisionCard); - expect(branchDropdowns.length).toBe(2); - expect(branchDropdowns.at(0).props('revisionText')).toBe('Source'); - expect(branchDropdowns.at(1).props('revisionText')).toBe('Target'); + expect(revisionCards.length).toBe(2); + expect(revisionCards.at(0).props('revisionText')).toBe('Source'); + expect(revisionCards.at(1).props('revisionText')).toBe('Target'); }); describe('compare button', () => { diff --git a/spec/frontend/projects/compare/components/repo_dropdown_spec.js b/spec/frontend/projects/compare/components/repo_dropdown_spec.js new file mode 100644 index 00000000000..af76632515c --- /dev/null +++ b/spec/frontend/projects/compare/components/repo_dropdown_spec.js @@ -0,0 +1,98 @@ +import { GlDropdown } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RepoDropdown from '~/projects/compare/components/repo_dropdown.vue'; + +const defaultProps = { + paramsName: 'to', +}; + +const projectToId = '1'; +const projectToName = 'some-to-name'; +const projectFromId = '2'; +const projectFromName = 'some-from-name'; + +const defaultProvide = { + projectTo: { id: projectToId, name: projectToName }, + projectsFrom: [ + { id: projectFromId, name: projectFromName }, + { id: 3, name: 'some-from-another-name' }, + ], +}; + +describe('RepoDropdown component', () => { + let wrapper; + + const createComponent = (props = {}, provide = {}) => { + wrapper = shallowMount(RepoDropdown, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findGlDropdown = () => wrapper.find(GlDropdown); + const findHiddenInput = () => wrapper.find('input[type="hidden"]'); + + describe('Source Revision', () => { + beforeEach(() => { + createComponent(); + }); + + it('set hidden input', () => { + expect(findHiddenInput().attributes('value')).toBe(projectToId); + }); + + it('displays the project name in the disabled dropdown', () => { + expect(findGlDropdown().props('text')).toBe(projectToName); + expect(findGlDropdown().props('disabled')).toBe(true); + }); + + it('does not emit `changeTargetProject` event', async () => { + wrapper.vm.emitTargetProject('foo'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('changeTargetProject')).toBeUndefined(); + }); + }); + + describe('Target Revision', () => { + beforeEach(() => { + createComponent({ paramsName: 'from' }); + }); + + it('set hidden input of the first project', () => { + expect(findHiddenInput().attributes('value')).toBe(projectFromId); + }); + + it('displays the first project name initially in the dropdown', () => { + expect(findGlDropdown().props('text')).toBe(projectFromName); + }); + + it('updates the hiddin input value when onClick method is triggered', async () => { + const repoId = '100'; + wrapper.vm.onClick({ id: repoId }); + await wrapper.vm.$nextTick(); + expect(findHiddenInput().attributes('value')).toBe(repoId); + }); + + it('emits initial `changeTargetProject` event with target project', () => { + expect(wrapper.emitted('changeTargetProject')).toEqual([[projectFromName]]); + }); + + it('emits `changeTargetProject` event when another target project is selected', async () => { + const newTargetProject = 'new-from-name'; + wrapper.vm.$emit('changeTargetProject', newTargetProject); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('changeTargetProject')[1]).toEqual([newTargetProject]); + }); + }); +}); diff --git a/spec/frontend/projects/compare/components/revision_card_spec.js b/spec/frontend/projects/compare/components/revision_card_spec.js new file mode 100644 index 00000000000..83f858f4454 --- /dev/null +++ b/spec/frontend/projects/compare/components/revision_card_spec.js @@ -0,0 +1,49 @@ +import { GlCard } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RepoDropdown from '~/projects/compare/components/repo_dropdown.vue'; +import RevisionCard from '~/projects/compare/components/revision_card.vue'; +import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vue'; + +const defaultProps = { + refsProjectPath: 'some/refs/path', + revisionText: 'Source', + paramsName: 'to', + paramsBranch: 'master', +}; + +describe('RepoDropdown component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(RevisionCard, { + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + GlCard, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + beforeEach(() => { + createComponent(); + }); + + it('displays revision text', () => { + expect(wrapper.find(GlCard).text()).toContain(defaultProps.revisionText); + }); + + it('renders RepoDropdown component', () => { + expect(wrapper.findAll(RepoDropdown).exists()).toBe(true); + }); + + it('renders RevisionDropdown component', () => { + expect(wrapper.findAll(RevisionDropdown).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js new file mode 100644 index 00000000000..270c89e674c --- /dev/null +++ b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js @@ -0,0 +1,106 @@ +import { GlDropdown } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import RevisionDropdown from '~/projects/compare/components/revision_dropdown_legacy.vue'; + +const defaultProps = { + refsProjectPath: 'some/refs/path', + revisionText: 'Target', + paramsName: 'from', + paramsBranch: 'master', +}; + +jest.mock('~/flash'); + +describe('RevisionDropdown component', () => { + let wrapper; + let axiosMock; + + const createComponent = (props = {}) => { + wrapper = shallowMount(RevisionDropdown, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + axiosMock.restore(); + }); + + const findGlDropdown = () => wrapper.find(GlDropdown); + + it('sets hidden input', () => { + createComponent(); + expect(wrapper.find('input[type="hidden"]').attributes('value')).toBe( + defaultProps.paramsBranch, + ); + }); + + it('update the branches on success', async () => { + const Branches = ['branch-1', 'branch-2']; + const Tags = ['tag-1', 'tag-2', 'tag-3']; + + axiosMock.onGet(defaultProps.refsProjectPath).replyOnce(200, { + Branches, + Tags, + }); + + createComponent(); + + await axios.waitForAll(); + + expect(wrapper.vm.branches).toEqual(Branches); + expect(wrapper.vm.tags).toEqual(Tags); + }); + + it('sets branches and tags to be an empty array when no tags or branches are given', async () => { + axiosMock.onGet(defaultProps.refsProjectPath).replyOnce(200, { + Branches: undefined, + Tags: undefined, + }); + + createComponent(); + + await axios.waitForAll(); + + expect(wrapper.vm.branches).toEqual([]); + expect(wrapper.vm.tags).toEqual([]); + }); + + it('shows flash message on error', async () => { + axiosMock.onGet('some/invalid/path').replyOnce(404); + + createComponent(); + + await wrapper.vm.fetchBranchesAndTags(); + expect(createFlash).toHaveBeenCalled(); + }); + + describe('GlDropdown component', () => { + it('renders props', () => { + createComponent(); + expect(wrapper.props()).toEqual(expect.objectContaining(defaultProps)); + }); + + it('display default text', () => { + createComponent({ + paramsBranch: null, + }); + expect(findGlDropdown().props('text')).toBe('Select branch/tag'); + }); + + it('display params branch text', () => { + createComponent(); + expect(findGlDropdown().props('text')).toBe(defaultProps.paramsBranch); + }); + }); +}); diff --git a/spec/frontend/projects/compare/components/revision_dropdown_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_spec.js index f3ff5e26d2b..69d3167c99c 100644 --- a/spec/frontend/projects/compare/components/revision_dropdown_spec.js +++ b/spec/frontend/projects/compare/components/revision_dropdown_spec.js @@ -7,7 +7,6 @@ import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vu const defaultProps = { refsProjectPath: 'some/refs/path', - revisionText: 'Target', paramsName: 'from', paramsBranch: 'master', }; @@ -57,7 +56,6 @@ describe('RevisionDropdown component', () => { createComponent(); await axios.waitForAll(); - expect(wrapper.vm.branches).toEqual(Branches); expect(wrapper.vm.tags).toEqual(Tags); }); @@ -71,6 +69,22 @@ describe('RevisionDropdown component', () => { expect(createFlash).toHaveBeenCalled(); }); + it('makes a new request when refsProjectPath is changed', async () => { + jest.spyOn(axios, 'get'); + + const newRefsProjectPath = 'new-selected-project-path'; + + createComponent(); + + wrapper.setProps({ + ...defaultProps, + refsProjectPath: newRefsProjectPath, + }); + + await axios.waitForAll(); + expect(axios.get).toHaveBeenLastCalledWith(newRefsProjectPath); + }); + describe('GlDropdown component', () => { it('renders props', () => { createComponent(); diff --git a/spec/frontend/projects/details/upload_button_spec.js b/spec/frontend/projects/details/upload_button_spec.js new file mode 100644 index 00000000000..ebb2b499ead --- /dev/null +++ b/spec/frontend/projects/details/upload_button_spec.js @@ -0,0 +1,61 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import UploadButton from '~/projects/details/upload_button.vue'; +import { trackFileUploadEvent } from '~/projects/upload_file_experiment_tracking'; +import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; + +jest.mock('~/projects/upload_file_experiment_tracking'); + +const MODAL_ID = 'details-modal-upload-blob'; + +describe('UploadButton', () => { + let wrapper; + let glModalDirective; + + const createComponent = () => { + glModalDirective = jest.fn(); + + return shallowMount(UploadButton, { + directives: { + glModal: { + bind(_, { value }) { + glModalDirective(value); + }, + }, + }, + }); + }; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays an upload button', () => { + expect(wrapper.find(GlButton).exists()).toBe(true); + }); + + it('contains a modal', () => { + const modal = wrapper.find(UploadBlobModal); + + expect(modal.exists()).toBe(true); + expect(modal.props('modalId')).toBe(MODAL_ID); + }); + + describe('when clickinig the upload file button', () => { + beforeEach(() => { + wrapper.find(GlButton).vm.$emit('click'); + }); + + it('tracks the click_upload_modal_trigger event', () => { + expect(trackFileUploadEvent).toHaveBeenCalledWith('click_upload_modal_trigger'); + }); + + it('opens the modal', () => { + expect(glModalDirective).toHaveBeenCalledWith(MODAL_ID); + }); + }); +}); diff --git a/spec/frontend/projects/experiment_new_project_creation/components/new_project_push_tip_popover_spec.js b/spec/frontend/projects/experiment_new_project_creation/components/new_project_push_tip_popover_spec.js new file mode 100644 index 00000000000..1ce16640d4a --- /dev/null +++ b/spec/frontend/projects/experiment_new_project_creation/components/new_project_push_tip_popover_spec.js @@ -0,0 +1,75 @@ +import { GlPopover, GlFormInputGroup } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import NewProjectPushTipPopover from '~/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +describe('New project push tip popover', () => { + let wrapper; + const targetId = 'target'; + const pushToCreateProjectCommand = 'command'; + const workingWithProjectsHelpPath = 'path'; + + const findPopover = () => wrapper.findComponent(GlPopover); + const findClipboardButton = () => wrapper.findComponent(ClipboardButton); + const findFormInput = () => wrapper.findComponent(GlFormInputGroup); + const findHelpLink = () => wrapper.find('a'); + const findTarget = () => document.getElementById(targetId); + + const buildWrapper = () => { + wrapper = shallowMount(NewProjectPushTipPopover, { + propsData: { + target: findTarget(), + }, + stubs: { + GlFormInputGroup, + }, + provide: { + pushToCreateProjectCommand, + workingWithProjectsHelpPath, + }, + }); + }; + + beforeEach(() => { + setFixtures(`<a id="${targetId}"></a>`); + buildWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders popover that targets the specified target', () => { + expect(findPopover().props()).toMatchObject({ + target: findTarget(), + triggers: 'click blur', + placement: 'top', + title: 'Push to create a project', + }); + }); + + it('renders a readonly form input with the push to create command', () => { + expect(findFormInput().props()).toMatchObject({ + value: pushToCreateProjectCommand, + selectOnClick: true, + }); + expect(findFormInput().attributes()).toMatchObject({ + 'aria-label': 'Push project from command line', + readonly: 'readonly', + }); + }); + + it('allows copying the push command using the clipboard button', () => { + expect(findClipboardButton().props()).toMatchObject({ + text: pushToCreateProjectCommand, + tooltipPlacement: 'right', + title: 'Copy command', + }); + }); + + it('displays a link to open the push command help page reference', () => { + expect(findHelpLink().attributes().href).toBe( + `${workingWithProjectsHelpPath}#push-to-create-a-new-project`, + ); + }); +}); diff --git a/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js b/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js index d6764f75262..f26d1a6d2a3 100644 --- a/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js +++ b/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { mockTracking } from 'helpers/tracking_helper'; +import NewProjectPushTipPopover from '~/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue'; import WelcomePage from '~/projects/experiment_new_project_creation/components/welcome.vue'; describe('Welcome page', () => { @@ -28,4 +29,13 @@ describe('Welcome page', () => { expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', { label: 'test' }); }); }); + + it('renders new project push tip popover', () => { + createComponent({ panels: [{ name: 'test', href: '#' }] }); + + const popover = wrapper.findComponent(NewProjectPushTipPopover); + + expect(popover.exists()).toBe(true); + expect(popover.props().target()).toBe(wrapper.find({ ref: 'clipTip' }).element); + }); }); diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js index f9fbb1b3016..8acf2376860 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js @@ -154,7 +154,7 @@ describe('ServiceDeskRoot', () => { }); it('shows an error message', () => { - expect(getAlertText()).toContain('An error occured while saving changes:'); + expect(getAlertText()).toContain('An error occurred while saving changes:'); }); }); }); diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js index f6744f4971e..5323c1afbb5 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js @@ -40,7 +40,7 @@ describe('ServiceDeskSetting', () => { }); it('should see activation checkbox', () => { - expect(findToggle().exists()).toBe(true); + expect(findToggle().props('label')).toBe(ServiceDeskSetting.i18n.toggleLabel); }); it('should see main panel with the email info', () => { diff --git a/spec/frontend/projects/upload_file_experiment_tracking_spec.js b/spec/frontend/projects/upload_file_experiment_tracking_spec.js new file mode 100644 index 00000000000..6817529e07e --- /dev/null +++ b/spec/frontend/projects/upload_file_experiment_tracking_spec.js @@ -0,0 +1,43 @@ +import ExperimentTracking from '~/experimentation/experiment_tracking'; +import { trackFileUploadEvent } from '~/projects/upload_file_experiment_tracking'; + +jest.mock('~/experimentation/experiment_tracking'); + +const eventName = 'click_upload_modal_form_submit'; +const fixture = `<a class='js-upload-file-experiment-trigger'></a><div class='project-home-panel empty-project'></div>`; + +beforeEach(() => { + document.body.innerHTML = fixture; +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('trackFileUploadEvent', () => { + it('initializes ExperimentTracking with the correct tracking event', () => { + trackFileUploadEvent(eventName); + + expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(eventName); + }); + + it('calls ExperimentTracking with the correct arguments', () => { + trackFileUploadEvent(eventName); + + expect(ExperimentTracking).toHaveBeenCalledWith('empty_repo_upload', { + label: 'blob-upload-modal', + property: 'empty', + }); + }); + + it('calls ExperimentTracking with the correct arguments when the project is not empty', () => { + document.querySelector('.empty-project').remove(); + + trackFileUploadEvent(eventName); + + expect(ExperimentTracking).toHaveBeenCalledWith('empty_repo_upload', { + label: 'blob-upload-modal', + property: 'nonempty', + }); + }); +}); diff --git a/spec/frontend/prometheus_metrics/custom_metrics_spec.js b/spec/frontend/prometheus_metrics/custom_metrics_spec.js index 3e3d4ee361a..20593351ee5 100644 --- a/spec/frontend/prometheus_metrics/custom_metrics_spec.js +++ b/spec/frontend/prometheus_metrics/custom_metrics_spec.js @@ -9,7 +9,6 @@ describe('PrometheusMetrics', () => { const customMetricsEndpoint = 'http://test.host/frontend-fixtures/services-project/prometheus/metrics'; let mock; - preloadFixtures(FIXTURE); beforeEach(() => { mock = new MockAdapter(axios); diff --git a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js index 722a5274ad4..a703dc0a66f 100644 --- a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js +++ b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js @@ -6,7 +6,6 @@ import { metrics2 as metrics, missingVarMetrics } from './mock_data'; describe('PrometheusMetrics', () => { const FIXTURE = 'services/prometheus/prometheus_service.html'; - preloadFixtures(FIXTURE); beforeEach(() => { loadFixtures(FIXTURE); diff --git a/spec/frontend/protected_branches/protected_branch_edit_spec.js b/spec/frontend/protected_branches/protected_branch_edit_spec.js new file mode 100644 index 00000000000..40e31e24a14 --- /dev/null +++ b/spec/frontend/protected_branches/protected_branch_edit_spec.js @@ -0,0 +1,88 @@ +import MockAdapter from 'axios-mock-adapter'; +import $ from 'jquery'; +import { TEST_HOST } from 'helpers/test_constants'; +import { deprecatedCreateFlash as flash } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import ProtectedBranchEdit from '~/protected_branches/protected_branch_edit'; + +jest.mock('~/flash'); + +const TEST_URL = `${TEST_HOST}/url`; +const IS_CHECKED_CLASS = 'is-checked'; + +describe('ProtectedBranchEdit', () => { + let mock; + + beforeEach(() => { + setFixtures(`<div id="wrap" data-url="${TEST_URL}"> + <button class="js-force-push-toggle">Toggle</button> + </div>`); + + jest.spyOn(ProtectedBranchEdit.prototype, 'buildDropdowns').mockImplementation(); + + mock = new MockAdapter(axios); + }); + + const findForcePushesToggle = () => document.querySelector('.js-force-push-toggle'); + + const create = ({ isChecked = false }) => { + if (isChecked) { + findForcePushesToggle().classList.add(IS_CHECKED_CLASS); + } + + return new ProtectedBranchEdit({ $wrap: $('#wrap'), hasLicense: false }); + }; + + afterEach(() => { + mock.restore(); + }); + + describe('when unchecked toggle button', () => { + let toggle; + + beforeEach(() => { + create({ isChecked: false }); + + toggle = findForcePushesToggle(); + }); + + it('is not changed', () => { + expect(toggle).not.toHaveClass(IS_CHECKED_CLASS); + expect(toggle).not.toBeDisabled(); + }); + + describe('when clicked', () => { + beforeEach(() => { + mock.onPatch(TEST_URL, { protected_branch: { allow_force_push: true } }).replyOnce(200, {}); + + toggle.click(); + }); + + it('checks and disables button', () => { + expect(toggle).toHaveClass(IS_CHECKED_CLASS); + expect(toggle).toBeDisabled(); + }); + + it('sends update to BE', () => + axios.waitForAll().then(() => { + // Args are asserted in the `.onPatch` call + expect(mock.history.patch).toHaveLength(1); + + expect(toggle).not.toBeDisabled(); + expect(flash).not.toHaveBeenCalled(); + })); + }); + + describe('when clicked and BE error', () => { + beforeEach(() => { + mock.onPatch(TEST_URL).replyOnce(500); + toggle.click(); + }); + + it('flashes error', () => + axios.waitForAll().then(() => { + expect(flash).toHaveBeenCalled(); + })); + }); + }); +}); diff --git a/spec/frontend/read_more_spec.js b/spec/frontend/read_more_spec.js index d1d01272403..16f0d7fb075 100644 --- a/spec/frontend/read_more_spec.js +++ b/spec/frontend/read_more_spec.js @@ -3,8 +3,6 @@ import initReadMore from '~/read_more'; describe('Read more click-to-expand functionality', () => { const fixtureName = 'projects/overview.html'; - preloadFixtures(fixtureName); - beforeEach(() => { loadFixtures(fixtureName); }); diff --git a/spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap b/spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap new file mode 100644 index 00000000000..5f05b7fc68b --- /dev/null +++ b/spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Ref selector component footer slot passes the expected slot props 1`] = ` +Object { + "isLoading": false, + "matches": Object { + "branches": Object { + "error": null, + "list": Array [ + Object { + "default": false, + "name": "add_images_and_changes", + }, + Object { + "default": false, + "name": "conflict-contains-conflict-markers", + }, + Object { + "default": false, + "name": "deleted-image-test", + }, + Object { + "default": false, + "name": "diff-files-image-to-symlink", + }, + Object { + "default": false, + "name": "diff-files-symlink-to-image", + }, + Object { + "default": false, + "name": "markdown", + }, + Object { + "default": true, + "name": "master", + }, + ], + "totalCount": 123, + }, + "commits": Object { + "error": null, + "list": Array [ + Object { + "name": "b83d6e39", + "subtitle": "Merge branch 'branch-merged' into 'master'", + "value": "b83d6e391c22777fca1ed3012fce84f633d7fed0", + }, + ], + "totalCount": 1, + }, + "tags": Object { + "error": null, + "list": Array [ + Object { + "name": "v1.1.1", + }, + Object { + "name": "v1.1.0", + }, + Object { + "name": "v1.0.0", + }, + ], + "totalCount": 456, + }, + }, + "query": "abcd1234", +} +`; diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js index 27ada131ed6..a642a8cf8c2 100644 --- a/spec/frontend/ref/components/ref_selector_spec.js +++ b/spec/frontend/ref/components/ref_selector_spec.js @@ -1,13 +1,20 @@ -import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem, GlDropdown, GlIcon } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import { merge, last } from 'lodash'; import Vuex from 'vuex'; import { trimText } from 'helpers/text_helper'; import { ENTER_KEY } from '~/lib/utils/keys'; import { sprintf } from '~/locale'; import RefSelector from '~/ref/components/ref_selector.vue'; -import { X_TOTAL_HEADER, DEFAULT_I18N } from '~/ref/constants'; +import { + X_TOTAL_HEADER, + DEFAULT_I18N, + REF_TYPE_BRANCHES, + REF_TYPE_TAGS, + REF_TYPE_COMMITS, +} from '~/ref/constants'; import createStore from '~/ref/stores/'; const localVue = createLocalVue(); @@ -26,27 +33,32 @@ describe('Ref selector component', () => { let branchesApiCallSpy; let tagsApiCallSpy; let commitApiCallSpy; - - const createComponent = (props = {}, attrs = {}) => { - wrapper = mount(RefSelector, { - propsData: { - projectId, - value: '', - ...props, - }, - attrs, - listeners: { - // simulate a parent component v-model binding - input: (selectedRef) => { - wrapper.setProps({ value: selectedRef }); + let requestSpies; + + const createComponent = (mountOverrides = {}) => { + wrapper = mount( + RefSelector, + merge( + { + propsData: { + projectId, + value: '', + }, + listeners: { + // simulate a parent component v-model binding + input: (selectedRef) => { + wrapper.setProps({ value: selectedRef }); + }, + }, + stubs: { + GlSearchBoxByType: true, + }, + localVue, + store: createStore(), }, - }, - stubs: { - GlSearchBoxByType: true, - }, - localVue, - store: createStore(), - }); + mountOverrides, + ), + ); }; beforeEach(() => { @@ -58,6 +70,7 @@ describe('Ref selector component', () => { .mockReturnValue([200, fixtures.branches, { [X_TOTAL_HEADER]: '123' }]); tagsApiCallSpy = jest.fn().mockReturnValue([200, fixtures.tags, { [X_TOTAL_HEADER]: '456' }]); commitApiCallSpy = jest.fn().mockReturnValue([200, fixtures.commit]); + requestSpies = { branchesApiCallSpy, tagsApiCallSpy, commitApiCallSpy }; mock .onGet(`/api/v4/projects/${projectId}/repository/branches`) @@ -78,7 +91,7 @@ describe('Ref selector component', () => { // // Finders // - const findButtonContent = () => wrapper.find('[data-testid="button-content"]'); + const findButtonContent = () => wrapper.find('button'); const findNoResults = () => wrapper.find('[data-testid="no-results"]'); @@ -175,7 +188,7 @@ describe('Ref selector component', () => { const id = 'git-ref'; beforeEach(() => { - createComponent({}, { id }); + createComponent({ attrs: { id } }); return waitForRequests(); }); @@ -189,7 +202,7 @@ describe('Ref selector component', () => { const preselectedRef = fixtures.branches[0].name; beforeEach(() => { - createComponent({ value: preselectedRef }); + createComponent({ propsData: { value: preselectedRef } }); return waitForRequests(); }); @@ -592,4 +605,152 @@ describe('Ref selector component', () => { }); }); }); + + describe('with non-default ref types', () => { + it.each` + enabledRefTypes | reqsCalled | reqsNotCalled + ${[REF_TYPE_BRANCHES]} | ${['branchesApiCallSpy']} | ${['tagsApiCallSpy', 'commitApiCallSpy']} + ${[REF_TYPE_TAGS]} | ${['tagsApiCallSpy']} | ${['branchesApiCallSpy', 'commitApiCallSpy']} + ${[REF_TYPE_COMMITS]} | ${[]} | ${['branchesApiCallSpy', 'tagsApiCallSpy', 'commitApiCallSpy']} + ${[REF_TYPE_TAGS, REF_TYPE_COMMITS]} | ${['tagsApiCallSpy']} | ${['branchesApiCallSpy', 'commitApiCallSpy']} + `( + 'only calls $reqsCalled requests when $enabledRefTypes are enabled', + async ({ enabledRefTypes, reqsCalled, reqsNotCalled }) => { + createComponent({ propsData: { enabledRefTypes } }); + + await waitForRequests(); + + reqsCalled.forEach((req) => expect(requestSpies[req]).toHaveBeenCalledTimes(1)); + reqsNotCalled.forEach((req) => expect(requestSpies[req]).not.toHaveBeenCalled()); + }, + ); + + it('only calls commitApiCallSpy when REF_TYPE_COMMITS is enabled', async () => { + createComponent({ propsData: { enabledRefTypes: [REF_TYPE_COMMITS] } }); + updateQuery('abcd1234'); + + await waitForRequests(); + + expect(commitApiCallSpy).toHaveBeenCalledTimes(1); + expect(branchesApiCallSpy).not.toHaveBeenCalled(); + expect(tagsApiCallSpy).not.toHaveBeenCalled(); + }); + + it('triggers another search if enabled ref types change', async () => { + createComponent({ propsData: { enabledRefTypes: [REF_TYPE_BRANCHES] } }); + await waitForRequests(); + + expect(branchesApiCallSpy).toHaveBeenCalledTimes(1); + expect(tagsApiCallSpy).not.toHaveBeenCalled(); + + wrapper.setProps({ + enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_TAGS], + }); + await waitForRequests(); + + expect(branchesApiCallSpy).toHaveBeenCalledTimes(2); + expect(tagsApiCallSpy).toHaveBeenCalledTimes(1); + }); + + it('if a ref type becomes disabled, its section is hidden, even if it had some results in store', async () => { + createComponent({ propsData: { enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_COMMITS] } }); + updateQuery('abcd1234'); + await waitForRequests(); + + expect(findBranchesSection().exists()).toBe(true); + expect(findCommitsSection().exists()).toBe(true); + + wrapper.setProps({ enabledRefTypes: [REF_TYPE_COMMITS] }); + await waitForRequests(); + + expect(findBranchesSection().exists()).toBe(false); + expect(findCommitsSection().exists()).toBe(true); + }); + + it.each` + enabledRefType | findVisibleSection | findHiddenSections + ${REF_TYPE_BRANCHES} | ${findBranchesSection} | ${[findTagsSection, findCommitsSection]} + ${REF_TYPE_TAGS} | ${findTagsSection} | ${[findBranchesSection, findCommitsSection]} + ${REF_TYPE_COMMITS} | ${findCommitsSection} | ${[findBranchesSection, findTagsSection]} + `( + 'hides section headers if a single ref type is enabled', + async ({ enabledRefType, findVisibleSection, findHiddenSections }) => { + createComponent({ propsData: { enabledRefTypes: [enabledRefType] } }); + updateQuery('abcd1234'); + await waitForRequests(); + + expect(findVisibleSection().exists()).toBe(true); + expect(findVisibleSection().find('[data-testid="section-header"]').exists()).toBe(false); + findHiddenSections.forEach((findHiddenSection) => + expect(findHiddenSection().exists()).toBe(false), + ); + }, + ); + }); + + describe('validation state', () => { + const invalidClass = 'gl-inset-border-1-red-500!'; + const isInvalidClassApplied = () => wrapper.find(GlDropdown).props('toggleClass')[invalidClass]; + + describe('valid state', () => { + describe('when the state prop is not provided', () => { + it('does not render a red border', () => { + createComponent(); + + expect(isInvalidClassApplied()).toBe(false); + }); + }); + + describe('when the state prop is true', () => { + it('does not render a red border', () => { + createComponent({ propsData: { state: true } }); + + expect(isInvalidClassApplied()).toBe(false); + }); + }); + }); + + describe('invalid state', () => { + it('renders the dropdown with a red border if the state prop is false', () => { + createComponent({ propsData: { state: false } }); + + expect(isInvalidClassApplied()).toBe(true); + }); + }); + }); + + describe('footer slot', () => { + const footerContent = 'This is the footer content'; + const createFooter = jest.fn().mockImplementation(function createMockFooter() { + return this.$createElement('div', { attrs: { 'data-testid': 'footer-content' } }, [ + footerContent, + ]); + }); + + beforeEach(() => { + createComponent({ + scopedSlots: { footer: createFooter }, + }); + + updateQuery('abcd1234'); + + return waitForRequests(); + }); + + afterEach(() => { + createFooter.mockClear(); + }); + + it('allows custom content to be shown at the bottom of the dropdown using the footer slot', () => { + expect(wrapper.find(`[data-testid="footer-content"]`).text()).toBe(footerContent); + }); + + it('passes the expected slot props', () => { + // The createFooter function gets called every time one of the scoped properties + // is updated. For the sake of this test, we'll just test the last call, which + // represents the final state of the slot props. + const lastCallProps = last(createFooter.mock.calls)[0]; + expect(lastCallProps).toMatchSnapshot(); + }); + }); }); diff --git a/spec/frontend/ref/stores/actions_spec.js b/spec/frontend/ref/stores/actions_spec.js index 11acec27165..099ce062a3a 100644 --- a/spec/frontend/ref/stores/actions_spec.js +++ b/spec/frontend/ref/stores/actions_spec.js @@ -1,4 +1,5 @@ import testAction from 'helpers/vuex_action_helper'; +import { ALL_REF_TYPES, REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS } from '~/ref/constants'; import * as actions from '~/ref/stores/actions'; import * as types from '~/ref/stores/mutation_types'; import createState from '~/ref/stores/state'; @@ -25,6 +26,14 @@ describe('Ref selector Vuex store actions', () => { state = createState(); }); + describe('setEnabledRefTypes', () => { + it(`commits ${types.SET_ENABLED_REF_TYPES} with the enabled ref types`, () => { + testAction(actions.setProjectId, ALL_REF_TYPES, state, [ + { type: types.SET_PROJECT_ID, payload: ALL_REF_TYPES }, + ]); + }); + }); + describe('setProjectId', () => { it(`commits ${types.SET_PROJECT_ID} with the new project ID`, () => { const projectId = '4'; @@ -46,12 +55,23 @@ describe('Ref selector Vuex store actions', () => { describe('search', () => { it(`commits ${types.SET_QUERY} with the new search query`, () => { const query = 'hello'; + testAction(actions.search, query, state, [{ type: types.SET_QUERY, payload: query }]); + }); + + it.each` + enabledRefTypes | expectedActions + ${[REF_TYPE_BRANCHES]} | ${['searchBranches']} + ${[REF_TYPE_COMMITS]} | ${['searchCommits']} + ${[REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS]} | ${['searchBranches', 'searchTags', 'searchCommits']} + `(`dispatches fetch actions for enabled ref types`, ({ enabledRefTypes, expectedActions }) => { + const query = 'hello'; + state.enabledRefTypes = enabledRefTypes; testAction( actions.search, query, state, [{ type: types.SET_QUERY, payload: query }], - [{ type: 'searchBranches' }, { type: 'searchTags' }, { type: 'searchCommits' }], + expectedActions.map((type) => ({ type })), ); }); }); diff --git a/spec/frontend/ref/stores/mutations_spec.js b/spec/frontend/ref/stores/mutations_spec.js index cda13089766..11d4fe0e206 100644 --- a/spec/frontend/ref/stores/mutations_spec.js +++ b/spec/frontend/ref/stores/mutations_spec.js @@ -1,4 +1,4 @@ -import { X_TOTAL_HEADER } from '~/ref/constants'; +import { X_TOTAL_HEADER, ALL_REF_TYPES } from '~/ref/constants'; import * as types from '~/ref/stores/mutation_types'; import mutations from '~/ref/stores/mutations'; import createState from '~/ref/stores/state'; @@ -13,6 +13,7 @@ describe('Ref selector Vuex store mutations', () => { describe('initial state', () => { it('is created with the correct structure and initial values', () => { expect(state).toEqual({ + enabledRefTypes: [], projectId: null, query: '', @@ -39,6 +40,14 @@ describe('Ref selector Vuex store mutations', () => { }); }); + describe(`${types.SET_ENABLED_REF_TYPES}`, () => { + it('sets the enabled ref types', () => { + mutations[types.SET_ENABLED_REF_TYPES](state, ALL_REF_TYPES); + + expect(state.enabledRefTypes).toBe(ALL_REF_TYPES); + }); + }); + describe(`${types.SET_PROJECT_ID}`, () => { it('updates the project ID', () => { const newProjectId = '4'; diff --git a/spec/frontend/registry/explorer/components/delete_button_spec.js b/spec/frontend/registry/explorer/components/delete_button_spec.js index a557d9afacc..4597c42add9 100644 --- a/spec/frontend/registry/explorer/components/delete_button_spec.js +++ b/spec/frontend/registry/explorer/components/delete_button_spec.js @@ -58,6 +58,7 @@ describe('delete_button', () => { title: 'Foo title', variant: 'danger', disabled: 'true', + category: 'secondary', }); }); diff --git a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js index 3fa3a2ae1de..b50ed87a563 100644 --- a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js @@ -1,9 +1,9 @@ -import { GlSprintf, GlButton } from '@gitlab/ui'; +import { GlButton, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { useFakeDate } from 'helpers/fake_date'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import component from '~/registry/explorer/components/details_page/details_header.vue'; import { - DETAILS_PAGE_TITLE, UNSCHEDULED_STATUS, SCHEDULED_STATUS, ONGOING_STATUS, @@ -13,6 +13,8 @@ import { CLEANUP_SCHEDULED_TOOLTIP, CLEANUP_ONGOING_TOOLTIP, CLEANUP_UNFINISHED_TOOLTIP, + ROOT_IMAGE_TEXT, + ROOT_IMAGE_TOOLTIP, } from '~/registry/explorer/constants'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; @@ -41,6 +43,7 @@ describe('Details Header', () => { const findTagsCount = () => findByTestId('tags-count'); const findCleanup = () => findByTestId('cleanup'); const findDeleteButton = () => wrapper.find(GlButton); + const findInfoIcon = () => wrapper.find(GlIcon); const waitForMetadataItems = async () => { // Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available @@ -51,8 +54,10 @@ describe('Details Header', () => { const mountComponent = (propsData = { image: defaultImage }) => { wrapper = shallowMount(component, { propsData, + directives: { + GlTooltip: createMockDirective(), + }, stubs: { - GlSprintf, TitleArea, }, }); @@ -62,15 +67,41 @@ describe('Details Header', () => { wrapper.destroy(); wrapper = null; }); + describe('image name', () => { + describe('missing image name', () => { + it('root image ', () => { + mountComponent({ image: { ...defaultImage, name: '' } }); - it('has the correct title ', () => { - mountComponent({ image: { ...defaultImage, name: '' } }); - expect(findTitle().text()).toMatchInterpolatedText(DETAILS_PAGE_TITLE); - }); + expect(findTitle().text()).toBe(ROOT_IMAGE_TEXT); + }); - it('shows imageName in the title', () => { - mountComponent(); - expect(findTitle().text()).toContain('foo'); + it('has an icon', () => { + mountComponent({ image: { ...defaultImage, name: '' } }); + + expect(findInfoIcon().exists()).toBe(true); + expect(findInfoIcon().props('name')).toBe('information-o'); + }); + + it('has a tooltip', () => { + mountComponent({ image: { ...defaultImage, name: '' } }); + + const tooltip = getBinding(findInfoIcon().element, 'gl-tooltip'); + expect(tooltip.value).toBe(ROOT_IMAGE_TOOLTIP); + }); + }); + + describe('with image name present', () => { + it('shows image.name ', () => { + mountComponent(); + expect(findTitle().text()).toContain('foo'); + }); + + it('has no icon', () => { + mountComponent(); + + expect(findInfoIcon().exists()).toBe(false); + }); + }); }); describe('delete button', () => { diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js index d6ee871341b..6c897b983f7 100644 --- a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js @@ -12,6 +12,7 @@ import { CLEANUP_TIMED_OUT_ERROR_MESSAGE, IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS, + ROOT_IMAGE_TEXT, } from '~/registry/explorer/constants'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; @@ -73,8 +74,8 @@ describe('Image List Row', () => { mountComponent(); const link = findDetailsLink(); - expect(link.html()).toContain(item.path); - expect(link.props('to')).toMatchObject({ + expect(link.text()).toBe(item.path); + expect(findDetailsLink().props('to')).toMatchObject({ name: 'details', params: { id: getIdFromGraphQLId(item.id), @@ -82,6 +83,12 @@ describe('Image List Row', () => { }); }); + it(`when the image has no name appends ${ROOT_IMAGE_TEXT} to the path`, () => { + mountComponent({ item: { ...item, name: '' } }); + + expect(findDetailsLink().text()).toBe(`${item.path}/ ${ROOT_IMAGE_TEXT}`); + }); + it('contains a clipboard button', () => { mountComponent(); const button = findClipboardButton(); diff --git a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js index 07256d2bbf5..11a3acd9eb9 100644 --- a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js @@ -4,7 +4,6 @@ import Component from '~/registry/explorer/components/list_page/registry_header. import { CONTAINER_REGISTRY_TITLE, LIST_INTRO_TEXT, - EXPIRATION_POLICY_DISABLED_MESSAGE, EXPIRATION_POLICY_DISABLED_TEXT, } from '~/registry/explorer/constants'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; @@ -132,41 +131,5 @@ describe('registry_header', () => { ]); }); }); - - describe('expiration policy info message', () => { - describe('when there are images', () => { - describe('when expiration policy is disabled', () => { - beforeEach(() => { - return mountComponent({ - expirationPolicy: { enabled: false }, - expirationPolicyHelpPagePath: 'foo', - imagesCount: 1, - }); - }); - - it('the prop is correctly bound', () => { - expect(findTitleArea().props('infoMessages')).toEqual([ - { text: LIST_INTRO_TEXT, link: '' }, - { text: EXPIRATION_POLICY_DISABLED_MESSAGE, link: 'foo' }, - ]); - }); - }); - - describe.each` - desc | props - ${'when there are no images'} | ${{ expirationPolicy: { enabled: false }, imagesCount: 0 }} - ${'when expiration policy is enabled'} | ${{ expirationPolicy: { enabled: true }, imagesCount: 1 }} - ${'when the expiration policy is completely disabled'} | ${{ expirationPolicy: { enabled: false }, imagesCount: 1, hideExpirationPolicyData: true }} - `('$desc', ({ props }) => { - it('message does not exist', () => { - mountComponent(props); - - expect(findTitleArea().props('infoMessages')).toEqual([ - { text: LIST_INTRO_TEXT, link: '' }, - ]); - }); - }); - }); - }); }); }); diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js index 65c58bf9874..76baf4f72c9 100644 --- a/spec/frontend/registry/explorer/pages/details_spec.js +++ b/spec/frontend/registry/explorer/pages/details_spec.js @@ -17,6 +17,8 @@ import { UNFINISHED_STATUS, DELETE_SCHEDULED, ALERT_DANGER_IMAGE, + MISSING_OR_DELETED_IMAGE_BREADCRUMB, + ROOT_IMAGE_TEXT, } from '~/registry/explorer/constants'; import deleteContainerRepositoryTagsMutation from '~/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; import getContainerRepositoryDetailsQuery from '~/registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; @@ -515,6 +517,26 @@ describe('Details Page', () => { expect(breadCrumbState.updateName).toHaveBeenCalledWith(containerRepositoryMock.name); }); + + it(`when the image is missing set the breadcrumb to ${MISSING_OR_DELETED_IMAGE_BREADCRUMB}`, async () => { + mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) }); + + await waitForApolloRequestRender(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(MISSING_OR_DELETED_IMAGE_BREADCRUMB); + }); + + it(`when the image has no name set the breadcrumb to ${ROOT_IMAGE_TEXT}`, async () => { + mountComponent({ + resolver: jest + .fn() + .mockResolvedValue(graphQLImageDetailsMock({ ...containerRepositoryMock, name: null })), + }); + + await waitForApolloRequestRender(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(ROOT_IMAGE_TEXT); + }); }); describe('when the image has a status different from null', () => { diff --git a/spec/frontend/registry/settings/components/expiration_toggle_spec.js b/spec/frontend/registry/settings/components/expiration_toggle_spec.js index 961bdfdf2c5..7598f6adc89 100644 --- a/spec/frontend/registry/settings/components/expiration_toggle_spec.js +++ b/spec/frontend/registry/settings/components/expiration_toggle_spec.js @@ -32,7 +32,7 @@ describe('ExpirationToggle', () => { it('has a toggle component', () => { mountComponent(); - expect(findToggle().exists()).toBe(true); + expect(findToggle().props('label')).toBe(component.i18n.toggleLabel); }); it('has a description', () => { diff --git a/spec/frontend/related_issues/components/related_issuable_input_spec.js b/spec/frontend/related_issues/components/related_issuable_input_spec.js new file mode 100644 index 00000000000..79b228454f4 --- /dev/null +++ b/spec/frontend/related_issues/components/related_issuable_input_spec.js @@ -0,0 +1,117 @@ +import { shallowMount } from '@vue/test-utils'; +import { TEST_HOST } from 'helpers/test_constants'; +import RelatedIssuableInput from '~/related_issues/components/related_issuable_input.vue'; +import { issuableTypesMap, PathIdSeparator } from '~/related_issues/constants'; + +jest.mock('ee_else_ce/gfm_auto_complete', () => { + return function gfmAutoComplete() { + return { + constructor() {}, + setup() {}, + }; + }; +}); + +describe('RelatedIssuableInput', () => { + let propsData; + + beforeEach(() => { + propsData = { + inputValue: '', + references: [], + pathIdSeparator: PathIdSeparator.Issue, + issuableType: issuableTypesMap.issue, + autoCompleteSources: { + issues: `${TEST_HOST}/h5bp/html5-boilerplate/-/autocomplete_sources/issues`, + }, + }; + }); + + describe('autocomplete', () => { + describe('with autoCompleteSources', () => { + it('shows placeholder text', () => { + const wrapper = shallowMount(RelatedIssuableInput, { propsData }); + + expect(wrapper.find({ ref: 'input' }).element.placeholder).toBe( + 'Paste issue link or <#issue id>', + ); + }); + + it('has GfmAutoComplete', () => { + const wrapper = shallowMount(RelatedIssuableInput, { propsData }); + + expect(wrapper.vm.gfmAutoComplete).toBeDefined(); + }); + }); + + describe('with no autoCompleteSources', () => { + it('shows placeholder text', () => { + const wrapper = shallowMount(RelatedIssuableInput, { + propsData: { + ...propsData, + references: ['!1', '!2'], + }, + }); + + expect(wrapper.find({ ref: 'input' }).element.value).toBe(''); + }); + + it('does not have GfmAutoComplete', () => { + const wrapper = shallowMount(RelatedIssuableInput, { + propsData: { + ...propsData, + autoCompleteSources: {}, + }, + }); + + expect(wrapper.vm.gfmAutoComplete).not.toBeDefined(); + }); + }); + }); + + describe('focus', () => { + it('when clicking anywhere on the input wrapper it should focus the input', async () => { + const wrapper = shallowMount(RelatedIssuableInput, { + propsData: { + ...propsData, + references: ['foo', 'bar'], + }, + // We need to attach to document, so that `document.activeElement` is properly set in jsdom + attachTo: document.body, + }); + + wrapper.find('li').trigger('click'); + + await wrapper.vm.$nextTick(); + + expect(document.activeElement).toBe(wrapper.find({ ref: 'input' }).element); + }); + }); + + describe('when filling in the input', () => { + it('emits addIssuableFormInput with data', () => { + const wrapper = shallowMount(RelatedIssuableInput, { + propsData, + }); + + wrapper.vm.$emit = jest.fn(); + + const newInputValue = 'filling in things'; + const untouchedRawReferences = newInputValue.trim().split(/\s/); + const touchedReference = untouchedRawReferences.pop(); + const input = wrapper.find({ ref: 'input' }); + + input.element.value = newInputValue; + input.element.selectionStart = newInputValue.length; + input.element.selectionEnd = newInputValue.length; + input.trigger('input'); + + expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormInput', { + newValue: newInputValue, + caretPos: newInputValue.length, + untouchedRawReferences, + touchedReference, + }); + }); + }); +}); diff --git a/spec/frontend/releases/components/tag_field_new_spec.js b/spec/frontend/releases/components/tag_field_new_spec.js index d87718138b8..387217c2a8e 100644 --- a/spec/frontend/releases/components/tag_field_new_spec.js +++ b/spec/frontend/releases/components/tag_field_new_spec.js @@ -1,6 +1,6 @@ -import { GlFormInput } from '@gitlab/ui'; +import { GlDropdownItem } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; -import RefSelector from '~/ref/components/ref_selector.vue'; +import Vue from 'vue'; import TagFieldNew from '~/releases/components/tag_field_new.vue'; import createStore from '~/releases/stores'; import createDetailModule from '~/releases/stores/modules/detail'; @@ -8,6 +8,25 @@ import createDetailModule from '~/releases/stores/modules/detail'; const TEST_TAG_NAME = 'test-tag-name'; const TEST_PROJECT_ID = '1234'; const TEST_CREATE_FROM = 'test-create-from'; +const NONEXISTENT_TAG_NAME = 'nonexistent-tag'; + +// A mock version of the RefSelector component that simulates +// a scenario where the users has searched for "nonexistent-tag" +// and the component has found no tags that match. +const RefSelectorStub = Vue.component('RefSelectorStub', { + data() { + return { + footerSlotProps: { + isLoading: false, + matches: { + tags: { totalCount: 0 }, + }, + query: NONEXISTENT_TAG_NAME, + }, + }; + }, + template: '<div><slot name="footer" v-bind="footerSlotProps"></slot></div>', +}); describe('releases/components/tag_field_new', () => { let store; @@ -17,7 +36,7 @@ describe('releases/components/tag_field_new', () => { wrapper = mountFn(TagFieldNew, { store, stubs: { - RefSelector: true, + RefSelector: RefSelectorStub, }, }); }; @@ -47,11 +66,12 @@ describe('releases/components/tag_field_new', () => { }); const findTagNameFormGroup = () => wrapper.find('[data-testid="tag-name-field"]'); - const findTagNameGlInput = () => findTagNameFormGroup().find(GlFormInput); - const findTagNameInput = () => findTagNameFormGroup().find('input'); + const findTagNameDropdown = () => findTagNameFormGroup().find(RefSelectorStub); const findCreateFromFormGroup = () => wrapper.find('[data-testid="create-from-field"]'); - const findCreateFromDropdown = () => findCreateFromFormGroup().find(RefSelector); + const findCreateFromDropdown = () => findCreateFromFormGroup().find(RefSelectorStub); + + const findCreateNewTagOption = () => wrapper.find(GlDropdownItem); describe('"Tag name" field', () => { describe('rendering and behavior', () => { @@ -61,14 +81,37 @@ describe('releases/components/tag_field_new', () => { expect(findTagNameFormGroup().attributes().label).toBe('Tag name'); }); - describe('when the user updates the field', () => { + describe('when the user selects a new tag name', () => { + beforeEach(async () => { + findCreateNewTagOption().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + }); + + it("updates the store's release.tagName property", () => { + expect(store.state.detail.release.tagName).toBe(NONEXISTENT_TAG_NAME); + }); + + it('hides the "Create from" field', () => { + expect(findCreateFromFormGroup().exists()).toBe(true); + }); + }); + + describe('when the user selects an existing tag name', () => { + const updatedTagName = 'updated-tag-name'; + + beforeEach(async () => { + findTagNameDropdown().vm.$emit('input', updatedTagName); + + await wrapper.vm.$nextTick(); + }); + it("updates the store's release.tagName property", () => { - const updatedTagName = 'updated-tag-name'; - findTagNameGlInput().vm.$emit('input', updatedTagName); + expect(store.state.detail.release.tagName).toBe(updatedTagName); + }); - return wrapper.vm.$nextTick().then(() => { - expect(store.state.detail.release.tagName).toBe(updatedTagName); - }); + it('shows the "Create from" field', () => { + expect(findCreateFromFormGroup().exists()).toBe(false); }); }); }); @@ -83,41 +126,39 @@ describe('releases/components/tag_field_new', () => { * @param {'shown' | 'hidden'} state The expected state of the validation message. * Should be passed either 'shown' or 'hidden' */ - const expectValidationMessageToBe = (state) => { - return wrapper.vm.$nextTick().then(() => { - expect(findTagNameFormGroup().element).toHaveClass( - state === 'shown' ? 'is-invalid' : 'is-valid', - ); - expect(findTagNameFormGroup().element).not.toHaveClass( - state === 'shown' ? 'is-valid' : 'is-invalid', - ); - }); + const expectValidationMessageToBe = async (state) => { + await wrapper.vm.$nextTick(); + + expect(findTagNameFormGroup().element).toHaveClass( + state === 'shown' ? 'is-invalid' : 'is-valid', + ); + expect(findTagNameFormGroup().element).not.toHaveClass( + state === 'shown' ? 'is-valid' : 'is-invalid', + ); }; describe('when the user has not yet interacted with the component', () => { - it('does not display a validation error', () => { - findTagNameInput().setValue(''); + it('does not display a validation error', async () => { + findTagNameDropdown().vm.$emit('input', ''); - return expectValidationMessageToBe('hidden'); + await expectValidationMessageToBe('hidden'); }); }); describe('when the user has interacted with the component and the value is not empty', () => { - it('does not display validation error', () => { - findTagNameInput().trigger('blur'); + it('does not display validation error', async () => { + findTagNameDropdown().vm.$emit('hide'); - return expectValidationMessageToBe('hidden'); + await expectValidationMessageToBe('hidden'); }); }); describe('when the user has interacted with the component and the value is empty', () => { - it('displays a validation error', () => { - const tagNameInput = findTagNameInput(); - - tagNameInput.setValue(''); - tagNameInput.trigger('blur'); + it('displays a validation error', async () => { + findTagNameDropdown().vm.$emit('input', ''); + findTagNameDropdown().vm.$emit('hide'); - return expectValidationMessageToBe('shown'); + await expectValidationMessageToBe('shown'); }); }); }); @@ -131,13 +172,13 @@ describe('releases/components/tag_field_new', () => { }); describe('when the user selects a git ref', () => { - it("updates the store's createFrom property", () => { + it("updates the store's createFrom property", async () => { const updatedCreateFrom = 'update-create-from'; findCreateFromDropdown().vm.$emit('input', updatedCreateFrom); - return wrapper.vm.$nextTick().then(() => { - expect(store.state.detail.createFrom).toBe(updatedCreateFrom); - }); + await wrapper.vm.$nextTick(); + + expect(store.state.detail.createFrom).toBe(updatedCreateFrom); }); }); }); diff --git a/spec/frontend/reports/components/summary_row_spec.js b/spec/frontend/reports/components/summary_row_spec.js index bdd6de1e0be..04d9d10dcd2 100644 --- a/spec/frontend/reports/components/summary_row_spec.js +++ b/spec/frontend/reports/components/summary_row_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import SummaryRow from '~/reports/components/summary_row.vue'; describe('Summary row', () => { @@ -14,16 +15,19 @@ describe('Summary row', () => { }; const createComponent = ({ propsData = {}, slots = {} } = {}) => { - wrapper = mount(SummaryRow, { - propsData: { - ...props, - ...propsData, - }, - slots, - }); + wrapper = extendedWrapper( + mount(SummaryRow, { + propsData: { + ...props, + ...propsData, + }, + slots, + }), + ); }; - const findSummary = () => wrapper.find('.report-block-list-issue-description-text'); + const findSummary = () => wrapper.findByTestId('summary-row-description'); + const findStatusIcon = () => wrapper.findByTestId('summary-row-icon'); afterEach(() => { wrapper.destroy(); @@ -37,9 +41,7 @@ describe('Summary row', () => { it('renders provided icon', () => { createComponent(); - expect(wrapper.find('.report-block-list-icon span').classes()).toContain( - 'js-ci-status-icon-warning', - ); + expect(findStatusIcon().classes()).toContain('js-ci-status-icon-warning'); }); describe('summary slot', () => { diff --git a/spec/frontend/reports/components/test_issue_body_spec.js b/spec/frontend/reports/components/test_issue_body_spec.js deleted file mode 100644 index 2843620a18d..00000000000 --- a/spec/frontend/reports/components/test_issue_body_spec.js +++ /dev/null @@ -1,72 +0,0 @@ -import Vue from 'vue'; -import { trimText } from 'helpers/text_helper'; -import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; -import component from '~/reports/components/test_issue_body.vue'; -import createStore from '~/reports/store'; -import { issue } from '../mock_data/mock_data'; - -describe('Test Issue body', () => { - let vm; - const Component = Vue.extend(component); - const store = createStore(); - - const commonProps = { - issue, - status: 'failed', - }; - - afterEach(() => { - vm.$destroy(); - }); - - describe('on click', () => { - it('calls openModal action', () => { - vm = mountComponentWithStore(Component, { - store, - props: commonProps, - }); - - jest.spyOn(vm, 'openModal').mockImplementation(() => {}); - - vm.$el.querySelector('button').click(); - - expect(vm.openModal).toHaveBeenCalledWith({ - issue: commonProps.issue, - }); - }); - }); - - describe('is new', () => { - beforeEach(() => { - vm = mountComponentWithStore(Component, { - store, - props: { ...commonProps, isNew: true }, - }); - }); - - it('renders issue name', () => { - expect(vm.$el.textContent).toContain(commonProps.issue.name); - }); - - it('renders new badge', () => { - expect(trimText(vm.$el.querySelector('.badge').textContent)).toEqual('New'); - }); - }); - - describe('not new', () => { - beforeEach(() => { - vm = mountComponentWithStore(Component, { - store, - props: commonProps, - }); - }); - - it('renders issue name', () => { - expect(vm.$el.textContent).toContain(commonProps.issue.name); - }); - - it('does not renders new badge', () => { - expect(vm.$el.querySelector('.badge')).toEqual(null); - }); - }); -}); diff --git a/spec/frontend/reports/components/modal_spec.js b/spec/frontend/reports/grouped_test_report/components/modal_spec.js index d47bb964e8a..303009bab3a 100644 --- a/spec/frontend/reports/components/modal_spec.js +++ b/spec/frontend/reports/grouped_test_report/components/modal_spec.js @@ -2,8 +2,8 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import ReportsModal from '~/reports/components/modal.vue'; -import state from '~/reports/store/state'; +import ReportsModal from '~/reports/grouped_test_report/components/modal.vue'; +import state from '~/reports/grouped_test_report/store/state'; import CodeBlock from '~/vue_shared/components/code_block.vue'; const StubbedGlModal = { template: '<div><slot></slot></div>', name: 'GlModal', props: ['title'] }; diff --git a/spec/frontend/reports/grouped_test_report/components/test_issue_body_spec.js b/spec/frontend/reports/grouped_test_report/components/test_issue_body_spec.js new file mode 100644 index 00000000000..e03a52aad8d --- /dev/null +++ b/spec/frontend/reports/grouped_test_report/components/test_issue_body_spec.js @@ -0,0 +1,97 @@ +import { GlBadge, GlButton } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import IssueStatusIcon from '~/reports/components/issue_status_icon.vue'; +import TestIssueBody from '~/reports/grouped_test_report/components/test_issue_body.vue'; +import { failedIssue, successIssue } from '../../mock_data/mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Test issue body', () => { + let wrapper; + let store; + + const findDescription = () => wrapper.findByTestId('test-issue-body-description'); + const findStatusIcon = () => wrapper.findComponent(IssueStatusIcon); + const findBadge = () => wrapper.findComponent(GlBadge); + + const actionSpies = { + openModal: jest.fn(), + }; + + const createComponent = ({ issue = failedIssue } = {}) => { + store = new Vuex.Store({ + actions: actionSpies, + }); + + wrapper = extendedWrapper( + shallowMount(TestIssueBody, { + store, + localVue, + propsData: { + issue, + }, + stubs: { + GlBadge, + GlButton, + IssueStatusIcon, + }, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when issue has failed status', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders issue name', () => { + expect(findDescription().text()).toBe(failedIssue.name); + }); + + it('renders failed status icon', () => { + expect(findStatusIcon().props('status')).toBe('failed'); + }); + + describe('when issue has recent failures', () => { + it('renders recent failures badge', () => { + expect(findBadge().exists()).toBe(true); + }); + }); + }); + + describe('when issue has success status', () => { + beforeEach(() => { + createComponent({ issue: successIssue }); + }); + + it('does not render recent failures', () => { + expect(findBadge().exists()).toBe(false); + }); + + it('renders issue name', () => { + expect(findDescription().text()).toBe(successIssue.name); + }); + + it('renders success status icon', () => { + expect(findStatusIcon().props('status')).toBe('success'); + }); + }); + + describe('when clicking on an issue', () => { + it('calls openModal action', () => { + createComponent(); + wrapper.findComponent(GlButton).trigger('click'); + + expect(actionSpies.openModal).toHaveBeenCalledWith(expect.any(Object), { + issue: failedIssue, + }); + }); + }); +}); diff --git a/spec/frontend/reports/components/grouped_test_reports_app_spec.js b/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js index ed261ed12c0..49332157691 100644 --- a/spec/frontend/reports/components/grouped_test_reports_app_spec.js +++ b/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js @@ -1,8 +1,8 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { mockTracking } from 'helpers/tracking_helper'; -import GroupedTestReportsApp from '~/reports/components/grouped_test_reports_app.vue'; -import { getStoreConfig } from '~/reports/store'; +import GroupedTestReportsApp from '~/reports/grouped_test_report/grouped_test_reports_app.vue'; +import { getStoreConfig } from '~/reports/grouped_test_report/store'; import { failedReport } from '../mock_data/mock_data'; import mixedResultsTestReports from '../mock_data/new_and_fixed_failures_report.json'; @@ -42,8 +42,12 @@ describe('Grouped test reports app', () => { const findHeader = () => wrapper.find('[data-testid="report-section-code-text"]'); const findExpandButton = () => wrapper.find('[data-testid="report-section-expand-button"]'); const findFullTestReportLink = () => wrapper.find('[data-testid="group-test-reports-full-link"]'); - const findSummaryDescription = () => wrapper.find('[data-testid="test-summary-row-description"]'); + const findSummaryDescription = () => wrapper.find('[data-testid="summary-row-description"]'); + const findIssueListUnresolvedHeading = () => wrapper.find('[data-testid="unresolvedHeading"]'); + const findIssueListResolvedHeading = () => wrapper.find('[data-testid="resolvedHeading"]'); const findIssueDescription = () => wrapper.find('[data-testid="test-issue-body-description"]'); + const findIssueRecentFailures = () => + wrapper.find('[data-testid="test-issue-body-recent-failures"]'); const findAllIssueDescriptions = () => wrapper.findAll('[data-testid="test-issue-body-description"]'); @@ -133,6 +137,10 @@ describe('Grouped test reports app', () => { mountComponent(); }); + it('renders New heading', () => { + expect(findIssueListUnresolvedHeading().text()).toBe('New'); + }); + it('renders failed summary text', () => { expect(findHeader().text()).toBe('Test summary contained 2 failed out of 11 total tests'); }); @@ -144,7 +152,6 @@ describe('Grouped test reports app', () => { }); it('renders failed issue in list', () => { - expect(findIssueDescription().text()).toContain('New'); expect(findIssueDescription().text()).toContain( 'Test#sum when a is 1 and b is 2 returns summary', ); @@ -157,6 +164,10 @@ describe('Grouped test reports app', () => { mountComponent(); }); + it('renders New heading', () => { + expect(findIssueListUnresolvedHeading().text()).toBe('New'); + }); + it('renders error summary text', () => { expect(findHeader().text()).toBe('Test summary contained 2 errors out of 11 total tests'); }); @@ -168,7 +179,6 @@ describe('Grouped test reports app', () => { }); it('renders error issue in list', () => { - expect(findIssueDescription().text()).toContain('New'); expect(findIssueDescription().text()).toContain( 'Test#sum when a is 1 and b is 2 returns summary', ); @@ -181,6 +191,11 @@ describe('Grouped test reports app', () => { mountComponent(); }); + it('renders New and Fixed headings', () => { + expect(findIssueListUnresolvedHeading().text()).toBe('New'); + expect(findIssueListResolvedHeading().text()).toBe('Fixed'); + }); + it('renders summary text', () => { expect(findHeader().text()).toBe( 'Test summary contained 2 failed and 2 fixed test results out of 11 total tests', @@ -194,7 +209,6 @@ describe('Grouped test reports app', () => { }); it('renders failed issue in list', () => { - expect(findIssueDescription().text()).toContain('New'); expect(findIssueDescription().text()).toContain( 'Test#subtract when a is 2 and b is 1 returns correct result', ); @@ -207,6 +221,10 @@ describe('Grouped test reports app', () => { mountComponent(); }); + it('renders Fixed heading', () => { + expect(findIssueListResolvedHeading().text()).toBe('Fixed'); + }); + it('renders summary text', () => { expect(findHeader().text()).toBe( 'Test summary contained 4 fixed test results out of 11 total tests', @@ -252,7 +270,7 @@ describe('Grouped test reports app', () => { }); it('renders the recent failures count on the test case', () => { - expect(findIssueDescription().text()).toContain( + expect(findIssueRecentFailures().text()).toBe( 'Failed 8 times in master in the last 14 days', ); }); @@ -295,6 +313,27 @@ describe('Grouped test reports app', () => { }); }); + describe('with a report parsing errors', () => { + beforeEach(() => { + const reports = failedReport; + reports.suites[0].suite_errors = { + head: 'JUnit XML parsing failed: 2:24: FATAL: attributes construct error', + base: 'JUnit data parsing failed: string not matched', + }; + setReports(reports); + mountComponent(); + }); + + it('renders the error messages', () => { + expect(findSummaryDescription().text()).toContain( + 'JUnit XML parsing failed: 2:24: FATAL: attributes construct error', + ); + expect(findSummaryDescription().text()).toContain( + 'JUnit data parsing failed: string not matched', + ); + }); + }); + describe('with error', () => { beforeEach(() => { mockStore.state.isLoading = false; diff --git a/spec/frontend/reports/store/actions_spec.js b/spec/frontend/reports/grouped_test_report/store/actions_spec.js index 25c3105466f..28633f7ba16 100644 --- a/spec/frontend/reports/store/actions_spec.js +++ b/spec/frontend/reports/grouped_test_report/store/actions_spec.js @@ -12,9 +12,9 @@ import { receiveReportsError, openModal, closeModal, -} from '~/reports/store/actions'; -import * as types from '~/reports/store/mutation_types'; -import state from '~/reports/store/state'; +} from '~/reports/grouped_test_report/store/actions'; +import * as types from '~/reports/grouped_test_report/store/mutation_types'; +import state from '~/reports/grouped_test_report/store/state'; describe('Reports Store Actions', () => { let mockedState; diff --git a/spec/frontend/reports/store/mutations_spec.js b/spec/frontend/reports/grouped_test_report/store/mutations_spec.js index 652b3b0ec45..60d5016a11b 100644 --- a/spec/frontend/reports/store/mutations_spec.js +++ b/spec/frontend/reports/grouped_test_report/store/mutations_spec.js @@ -1,7 +1,7 @@ -import * as types from '~/reports/store/mutation_types'; -import mutations from '~/reports/store/mutations'; -import state from '~/reports/store/state'; -import { issue } from '../mock_data/mock_data'; +import * as types from '~/reports/grouped_test_report/store/mutation_types'; +import mutations from '~/reports/grouped_test_report/store/mutations'; +import state from '~/reports/grouped_test_report/store/state'; +import { failedIssue } from '../../mock_data/mock_data'; describe('Reports Store Mutations', () => { let stateCopy; @@ -115,17 +115,17 @@ describe('Reports Store Mutations', () => { describe('SET_ISSUE_MODAL_DATA', () => { beforeEach(() => { mutations[types.SET_ISSUE_MODAL_DATA](stateCopy, { - issue, + issue: failedIssue, }); }); it('should set modal title', () => { - expect(stateCopy.modal.title).toEqual(issue.name); + expect(stateCopy.modal.title).toEqual(failedIssue.name); }); it('should set modal data', () => { - expect(stateCopy.modal.data.execution_time.value).toEqual(issue.execution_time); - expect(stateCopy.modal.data.system_output.value).toEqual(issue.system_output); + expect(stateCopy.modal.data.execution_time.value).toEqual(failedIssue.execution_time); + expect(stateCopy.modal.data.system_output.value).toEqual(failedIssue.system_output); }); it('should open modal', () => { @@ -136,7 +136,7 @@ describe('Reports Store Mutations', () => { describe('RESET_ISSUE_MODAL_DATA', () => { beforeEach(() => { mutations[types.SET_ISSUE_MODAL_DATA](stateCopy, { - issue, + issue: failedIssue, }); mutations[types.RESET_ISSUE_MODAL_DATA](stateCopy); diff --git a/spec/frontend/reports/store/utils_spec.js b/spec/frontend/reports/grouped_test_report/store/utils_spec.js index cbc87bbb5ec..63320744796 100644 --- a/spec/frontend/reports/store/utils_spec.js +++ b/spec/frontend/reports/grouped_test_report/store/utils_spec.js @@ -5,7 +5,7 @@ import { ICON_SUCCESS, ICON_NOTFOUND, } from '~/reports/constants'; -import * as utils from '~/reports/store/utils'; +import * as utils from '~/reports/grouped_test_report/store/utils'; describe('Reports store utils', () => { describe('summaryTextbuilder', () => { diff --git a/spec/frontend/reports/mock_data/mock_data.js b/spec/frontend/reports/mock_data/mock_data.js index 3caaab2fd79..68c7439df47 100644 --- a/spec/frontend/reports/mock_data/mock_data.js +++ b/spec/frontend/reports/mock_data/mock_data.js @@ -1,9 +1,23 @@ -export const issue = { +export const failedIssue = { result: 'failure', name: 'Test#sum when a is 1 and b is 2 returns summary', execution_time: 0.009411, + status: 'failed', system_output: "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in \u003ctop (required)\u003e'", + recent_failures: { + count: 3, + base_branch: 'master', + }, +}; + +export const successIssue = { + result: 'success', + name: 'Test#sum when a is 1 and b is 2 returns summary', + execution_time: 0.009411, + status: 'success', + system_output: null, + recent_failures: null, }; export const failedReport = { diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js new file mode 100644 index 00000000000..935ed08f67a --- /dev/null +++ b/spec/frontend/repository/components/upload_blob_modal_spec.js @@ -0,0 +1,203 @@ +import { GlModal, GlFormInput, GlFormTextarea, GlToggle, GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import httpStatusCodes from '~/lib/utils/http_status'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { trackFileUploadEvent } from '~/projects/upload_file_experiment_tracking'; +import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; +import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; + +jest.mock('~/projects/upload_file_experiment_tracking'); +jest.mock('~/flash'); +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), + joinPaths: () => '/new_upload', +})); + +const initialProps = { + modalId: 'upload-blob', + commitMessage: 'Upload New File', + targetBranch: 'master', + originalBranch: 'master', + canPushCode: true, + path: 'new_upload', +}; + +describe('UploadBlobModal', () => { + let wrapper; + let mock; + + const mockEvent = { preventDefault: jest.fn() }; + + const createComponent = (props) => { + wrapper = shallowMount(UploadBlobModal, { + propsData: { + ...initialProps, + ...props, + }, + mocks: { + $route: { + params: { + path: '', + }, + }, + }, + }); + }; + + const findModal = () => wrapper.find(GlModal); + const findAlert = () => wrapper.find(GlAlert); + const findCommitMessage = () => wrapper.find(GlFormTextarea); + const findBranchName = () => wrapper.find(GlFormInput); + const findMrToggle = () => wrapper.find(GlToggle); + const findUploadDropzone = () => wrapper.find(UploadDropzone); + const actionButtonDisabledState = () => findModal().props('actionPrimary').attributes[0].disabled; + const cancelButtonDisabledState = () => findModal().props('actionCancel').attributes[0].disabled; + const actionButtonLoadingState = () => findModal().props('actionPrimary').attributes[0].loading; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe.each` + canPushCode | displayBranchName | displayForkedBranchMessage + ${true} | ${true} | ${false} + ${false} | ${false} | ${true} + `( + 'canPushCode = $canPushCode', + ({ canPushCode, displayBranchName, displayForkedBranchMessage }) => { + beforeEach(() => { + createComponent({ canPushCode }); + }); + + it('displays the modal', () => { + expect(findModal().exists()).toBe(true); + }); + + it('includes the upload dropzone', () => { + expect(findUploadDropzone().exists()).toBe(true); + }); + + it('includes the commit message', () => { + expect(findCommitMessage().exists()).toBe(true); + }); + + it('displays the disabled upload button', () => { + expect(actionButtonDisabledState()).toBe(true); + }); + + it('displays the enabled cancel button', () => { + expect(cancelButtonDisabledState()).toBe(false); + }); + + it('does not display the MR toggle', () => { + expect(findMrToggle().exists()).toBe(false); + }); + + it(`${ + displayForkedBranchMessage ? 'displays' : 'does not display' + } the forked branch message`, () => { + expect(findAlert().exists()).toBe(displayForkedBranchMessage); + }); + + it(`${displayBranchName ? 'displays' : 'does not display'} the branch name`, () => { + expect(findBranchName().exists()).toBe(displayBranchName); + }); + + if (canPushCode) { + describe('when changing the branch name', () => { + it('displays the MR toggle', async () => { + wrapper.setData({ target: 'Not master' }); + + await wrapper.vm.$nextTick(); + + expect(findMrToggle().exists()).toBe(true); + }); + }); + } + + describe('completed form', () => { + beforeEach(() => { + wrapper.setData({ + file: { type: 'jpg' }, + filePreviewURL: 'http://file.com?format=jpg', + }); + }); + + it('enables the upload button when the form is completed', () => { + expect(actionButtonDisabledState()).toBe(false); + }); + + describe('form submission', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + + findModal().vm.$emit('primary', mockEvent); + }); + + afterEach(() => { + mock.restore(); + }); + + it('disables the upload button', () => { + expect(actionButtonDisabledState()).toBe(true); + }); + + it('sets the upload button to loading', () => { + expect(actionButtonLoadingState()).toBe(true); + }); + }); + + describe('successful response', () => { + beforeEach(async () => { + mock = new MockAdapter(axios); + mock.onPost(initialProps.path).reply(httpStatusCodes.OK, { filePath: 'blah' }); + + findModal().vm.$emit('primary', mockEvent); + + await waitForPromises(); + }); + + it('tracks the click_upload_modal_trigger event when opening the modal', () => { + expect(trackFileUploadEvent).toHaveBeenCalledWith('click_upload_modal_form_submit'); + }); + + it('redirects to the uploaded file', () => { + expect(visitUrl).toHaveBeenCalled(); + }); + + afterEach(() => { + mock.restore(); + }); + }); + + describe('error response', () => { + beforeEach(async () => { + mock = new MockAdapter(axios); + mock.onPost(initialProps.path).timeout(); + + findModal().vm.$emit('primary', mockEvent); + + await waitForPromises(); + }); + + it('does not track an event', () => { + expect(trackFileUploadEvent).not.toHaveBeenCalled(); + }); + + it('creates a flash error', () => { + expect(createFlash).toHaveBeenCalledWith('Error uploading file. Please try again.'); + }); + + afterEach(() => { + mock.restore(); + }); + }); + }); + }, + ); +}); diff --git a/spec/frontend/right_sidebar_spec.js b/spec/frontend/right_sidebar_spec.js index f3719b28baa..8699e1cf420 100644 --- a/spec/frontend/right_sidebar_spec.js +++ b/spec/frontend/right_sidebar_spec.js @@ -27,7 +27,6 @@ const assertSidebarState = (state) => { describe('RightSidebar', () => { describe('fixture tests', () => { const fixtureName = 'issues/open-issue.html'; - preloadFixtures(fixtureName); let mock; beforeEach(() => { diff --git a/spec/frontend/search/highlight_blob_search_result_spec.js b/spec/frontend/search/highlight_blob_search_result_spec.js index c1b0c7d794b..6908bcbd283 100644 --- a/spec/frontend/search/highlight_blob_search_result_spec.js +++ b/spec/frontend/search/highlight_blob_search_result_spec.js @@ -4,8 +4,6 @@ const fixture = 'search/blob_search_result.html'; const searchKeyword = 'Send'; // spec/frontend/fixtures/search.rb#79 describe('search/highlight_blob_search_result', () => { - preloadFixtures(fixture); - beforeEach(() => loadFixtures(fixture)); it('highlights lines with search term occurrence', () => { diff --git a/spec/frontend/search_autocomplete_spec.js b/spec/frontend/search_autocomplete_spec.js index a9fbe0fe552..5aca07d59e4 100644 --- a/spec/frontend/search_autocomplete_spec.js +++ b/spec/frontend/search_autocomplete_spec.js @@ -105,7 +105,6 @@ describe('Search autocomplete dropdown', () => { expect(list.find(mrsIHaveCreatedLink).text()).toBe("Merge requests I've created"); }; - preloadFixtures('static/search_autocomplete.html'); beforeEach(() => { loadFixtures('static/search_autocomplete.html'); diff --git a/spec/frontend/security_configuration/configuration_table_spec.js b/spec/frontend/security_configuration/configuration_table_spec.js index 49f9a7a3ea8..b8a574dc4e0 100644 --- a/spec/frontend/security_configuration/configuration_table_spec.js +++ b/spec/frontend/security_configuration/configuration_table_spec.js @@ -1,15 +1,11 @@ import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import ConfigurationTable from '~/security_configuration/components/configuration_table.vue'; -import { features, UPGRADE_CTA } from '~/security_configuration/components/features_constants'; +import { scanners, UPGRADE_CTA } from '~/security_configuration/components/scanners_constants'; import { REPORT_TYPE_SAST, - REPORT_TYPE_DAST, - REPORT_TYPE_DEPENDENCY_SCANNING, - REPORT_TYPE_CONTAINER_SCANNING, - REPORT_TYPE_COVERAGE_FUZZING, - REPORT_TYPE_LICENSE_COMPLIANCE, + REPORT_TYPE_SECRET_DETECTION, } from '~/vue_shared/security_reports/constants'; describe('Configuration Table Component', () => { @@ -19,6 +15,8 @@ describe('Configuration Table Component', () => { wrapper = extendedWrapper(mount(ConfigurationTable, {})); }; + const findHelpLinks = () => wrapper.findAll('[data-testid="help-link"]'); + afterEach(() => { wrapper.destroy(); }); @@ -27,22 +25,20 @@ describe('Configuration Table Component', () => { createComponent(); }); - it.each(features)('should match strings', (feature) => { - expect(wrapper.text()).toContain(feature.name); - expect(wrapper.text()).toContain(feature.description); - - if (feature.type === REPORT_TYPE_SAST) { - expect(wrapper.findByTestId(feature.type).text()).toBe('Configure via Merge Request'); - } else if ( - [ - REPORT_TYPE_DAST, - REPORT_TYPE_DEPENDENCY_SCANNING, - REPORT_TYPE_CONTAINER_SCANNING, - REPORT_TYPE_COVERAGE_FUZZING, - REPORT_TYPE_LICENSE_COMPLIANCE, - ].includes(feature.type) - ) { - expect(wrapper.findByTestId(feature.type).text()).toMatchInterpolatedText(UPGRADE_CTA); - } + describe.each(scanners.map((scanner, i) => [scanner, i]))('given scanner %s', (scanner, i) => { + it('should match strings', () => { + expect(wrapper.text()).toContain(scanner.name); + expect(wrapper.text()).toContain(scanner.description); + if (scanner.type === REPORT_TYPE_SAST) { + expect(wrapper.findByTestId(scanner.type).text()).toBe('Configure via Merge Request'); + } else if (scanner.type !== REPORT_TYPE_SECRET_DETECTION) { + expect(wrapper.findByTestId(scanner.type).text()).toMatchInterpolatedText(UPGRADE_CTA); + } + }); + + it('should show expected help link', () => { + const helpLink = findHelpLinks().at(i); + expect(helpLink.attributes('href')).toBe(scanner.helpPath); + }); }); }); diff --git a/spec/frontend/security_configuration/upgrade_spec.js b/spec/frontend/security_configuration/upgrade_spec.js index 0ab1108b265..1f0cc795fc5 100644 --- a/spec/frontend/security_configuration/upgrade_spec.js +++ b/spec/frontend/security_configuration/upgrade_spec.js @@ -1,28 +1,29 @@ import { mount } from '@vue/test-utils'; -import { UPGRADE_CTA } from '~/security_configuration/components/features_constants'; +import { UPGRADE_CTA } from '~/security_configuration/components/scanners_constants'; import Upgrade from '~/security_configuration/components/upgrade.vue'; +const TEST_URL = 'http://www.example.test'; let wrapper; -const createComponent = () => { - wrapper = mount(Upgrade, {}); +const createComponent = (componentData = {}) => { + wrapper = mount(Upgrade, componentData); }; -beforeEach(() => { - createComponent(); -}); - afterEach(() => { wrapper.destroy(); }); describe('Upgrade component', () => { + beforeEach(() => { + createComponent({ provide: { upgradePath: TEST_URL } }); + }); + it('renders correct text in link', () => { expect(wrapper.text()).toMatchInterpolatedText(UPGRADE_CTA); }); - it('renders link with correct attributes', () => { + it('renders link with correct default attributes', () => { expect(wrapper.find('a').attributes()).toMatchObject({ - href: 'https://about.gitlab.com/pricing/', + href: TEST_URL, target: '_blank', }); }); diff --git a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap index bd05eb69080..226e580a8e8 100644 --- a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap +++ b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap @@ -45,13 +45,10 @@ exports[`self monitor component When the self monitor project has not been creat Enabling this feature creates a project that can be used to monitor the health of your instance. </p> - <gl-form-group-stub - label="Create Project" - label-for="self-monitor-toggle" - > + <gl-form-group-stub> <gl-toggle-stub + label="Create Project" labelposition="top" - name="self-monitor-toggle" /> </gl-form-group-stub> </form> diff --git a/spec/frontend/self_monitor/components/self_monitor_form_spec.js b/spec/frontend/self_monitor/components/self_monitor_form_spec.js index 5f5934305c6..e6962e4c453 100644 --- a/spec/frontend/self_monitor/components/self_monitor_form_spec.js +++ b/spec/frontend/self_monitor/components/self_monitor_form_spec.js @@ -1,4 +1,4 @@ -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlToggle } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; import SelfMonitor from '~/self_monitor/components/self_monitor_form.vue'; @@ -82,6 +82,14 @@ describe('self monitor component', () => { wrapper.find({ ref: 'selfMonitoringFormText' }).find('a').attributes('href'), ).toEqual(`${TEST_HOST}/instance-administrators-random/gitlab-self-monitoring`); }); + + it('renders toggle', () => { + wrapper = shallowMount(SelfMonitor, { store }); + + expect(wrapper.findComponent(GlToggle).props('label')).toBe( + SelfMonitor.formLabels.createProject, + ); + }); }); }); }); diff --git a/spec/frontend/sentry/sentry_config_spec.js b/spec/frontend/sentry/sentry_config_spec.js index f7102f9b2f9..1f5097ef2a8 100644 --- a/spec/frontend/sentry/sentry_config_spec.js +++ b/spec/frontend/sentry/sentry_config_spec.js @@ -1,5 +1,5 @@ +import * as Sentry from '@sentry/browser'; import SentryConfig from '~/sentry/sentry_config'; -import * as Sentry from '~/sentry/wrapper'; describe('SentryConfig', () => { describe('IGNORE_ERRORS', () => { diff --git a/spec/frontend/settings_panels_spec.js b/spec/frontend/settings_panels_spec.js index 8666106d3c6..6b739617b97 100644 --- a/spec/frontend/settings_panels_spec.js +++ b/spec/frontend/settings_panels_spec.js @@ -2,8 +2,6 @@ import $ from 'jquery'; import initSettingsPanels, { isExpanded } from '~/settings_panels'; describe('Settings Panels', () => { - preloadFixtures('groups/edit.html'); - beforeEach(() => { loadFixtures('groups/edit.html'); }); diff --git a/spec/frontend/shared/popover_spec.js b/spec/frontend/shared/popover_spec.js deleted file mode 100644 index 59b0b3b006c..00000000000 --- a/spec/frontend/shared/popover_spec.js +++ /dev/null @@ -1,166 +0,0 @@ -import $ from 'jquery'; -import { togglePopover, mouseleave, mouseenter } from '~/shared/popover'; - -describe('popover', () => { - describe('togglePopover', () => { - describe('togglePopover(true)', () => { - it('returns true when popover is shown', () => { - const context = { - hasClass: () => false, - popover: () => {}, - toggleClass: () => {}, - }; - - expect(togglePopover.call(context, true)).toEqual(true); - }); - - it('returns false when popover is already shown', () => { - const context = { - hasClass: () => true, - }; - - expect(togglePopover.call(context, true)).toEqual(false); - }); - - it('shows popover', (done) => { - const context = { - hasClass: () => false, - popover: () => {}, - toggleClass: () => {}, - }; - - jest.spyOn(context, 'popover').mockImplementation((method) => { - expect(method).toEqual('show'); - done(); - }); - - togglePopover.call(context, true); - }); - - it('adds disable-animation and js-popover-show class', (done) => { - const context = { - hasClass: () => false, - popover: () => {}, - toggleClass: () => {}, - }; - - jest.spyOn(context, 'toggleClass').mockImplementation((classNames, show) => { - expect(classNames).toEqual('disable-animation js-popover-show'); - expect(show).toEqual(true); - done(); - }); - - togglePopover.call(context, true); - }); - }); - - describe('togglePopover(false)', () => { - it('returns true when popover is hidden', () => { - const context = { - hasClass: () => true, - popover: () => {}, - toggleClass: () => {}, - }; - - expect(togglePopover.call(context, false)).toEqual(true); - }); - - it('returns false when popover is already hidden', () => { - const context = { - hasClass: () => false, - }; - - expect(togglePopover.call(context, false)).toEqual(false); - }); - - it('hides popover', (done) => { - const context = { - hasClass: () => true, - popover: () => {}, - toggleClass: () => {}, - }; - - jest.spyOn(context, 'popover').mockImplementation((method) => { - expect(method).toEqual('hide'); - done(); - }); - - togglePopover.call(context, false); - }); - - it('removes disable-animation and js-popover-show class', (done) => { - const context = { - hasClass: () => true, - popover: () => {}, - toggleClass: () => {}, - }; - - jest.spyOn(context, 'toggleClass').mockImplementation((classNames, show) => { - expect(classNames).toEqual('disable-animation js-popover-show'); - expect(show).toEqual(false); - done(); - }); - - togglePopover.call(context, false); - }); - }); - }); - - describe('mouseleave', () => { - it('calls hide popover if .popover:hover is false', () => { - const fakeJquery = { - length: 0, - }; - - jest - .spyOn($.fn, 'init') - .mockImplementation((selector) => (selector === '.popover:hover' ? fakeJquery : $.fn)); - jest.spyOn(togglePopover, 'call').mockImplementation(() => {}); - mouseleave(); - - expect(togglePopover.call).toHaveBeenCalledWith(expect.any(Object), false); - }); - - it('does not call hide popover if .popover:hover is true', () => { - const fakeJquery = { - length: 1, - }; - - jest - .spyOn($.fn, 'init') - .mockImplementation((selector) => (selector === '.popover:hover' ? fakeJquery : $.fn)); - jest.spyOn(togglePopover, 'call').mockImplementation(() => {}); - mouseleave(); - - expect(togglePopover.call).not.toHaveBeenCalledWith(false); - }); - }); - - describe('mouseenter', () => { - const context = {}; - - it('shows popover', () => { - jest.spyOn(togglePopover, 'call').mockReturnValue(false); - mouseenter.call(context); - - expect(togglePopover.call).toHaveBeenCalledWith(expect.any(Object), true); - }); - - it('registers mouseleave event if popover is showed', (done) => { - jest.spyOn(togglePopover, 'call').mockReturnValue(true); - jest.spyOn($.fn, 'on').mockImplementation((eventName) => { - expect(eventName).toEqual('mouseleave'); - done(); - }); - mouseenter.call(context); - }); - - it('does not register mouseleave event if popover is not showed', () => { - jest.spyOn(togglePopover, 'call').mockReturnValue(false); - const spy = jest.spyOn($.fn, 'on').mockImplementation(() => {}); - mouseenter.call(context); - - expect(spy).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js index 1650dd2c1ca..fc5eeee9687 100644 --- a/spec/frontend/shortcuts_spec.js +++ b/spec/frontend/shortcuts_spec.js @@ -20,8 +20,6 @@ describe('Shortcuts', () => { target, }); - preloadFixtures(fixtureName); - beforeEach(() => { loadFixtures(fixtureName); diff --git a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap deleted file mode 100644 index 2367667544d..00000000000 --- a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap +++ /dev/null @@ -1,199 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = false 1`] = ` -<div - class="block issuable-sidebar-item confidentiality" - iid="" -> - <div - class="sidebar-collapsed-icon" - title="Not confidential" - > - <gl-icon-stub - name="eye" - size="16" - /> - </div> - - <div - class="title hide-collapsed" - > - - Confidentiality - - <!----> - </div> - - <div - class="value sidebar-item-value hide-collapsed" - > - <!----> - - <div - class="no-value sidebar-item-value" - data-testid="not-confidential" - > - <gl-icon-stub - class="sidebar-item-icon inline" - name="eye" - size="16" - /> - - Not confidential - - </div> - </div> -</div> -`; - -exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = true 1`] = ` -<div - class="block issuable-sidebar-item confidentiality" - iid="" -> - <div - class="sidebar-collapsed-icon" - title="Not confidential" - > - <gl-icon-stub - name="eye" - size="16" - /> - </div> - - <div - class="title hide-collapsed" - > - - Confidentiality - - <a - class="float-right confidential-edit" - data-track-event="click_edit_button" - data-track-label="right_sidebar" - data-track-property="confidentiality" - href="#" - > - Edit - </a> - </div> - - <div - class="value sidebar-item-value hide-collapsed" - > - <!----> - - <div - class="no-value sidebar-item-value" - data-testid="not-confidential" - > - <gl-icon-stub - class="sidebar-item-icon inline" - name="eye" - size="16" - /> - - Not confidential - - </div> - </div> -</div> -`; - -exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = false 1`] = ` -<div - class="block issuable-sidebar-item confidentiality" - iid="" -> - <div - class="sidebar-collapsed-icon" - title="Confidential" - > - <gl-icon-stub - name="eye-slash" - size="16" - /> - </div> - - <div - class="title hide-collapsed" - > - - Confidentiality - - <!----> - </div> - - <div - class="value sidebar-item-value hide-collapsed" - > - <!----> - - <div - class="value sidebar-item-value hide-collapsed" - > - <gl-icon-stub - class="sidebar-item-icon inline is-active" - name="eye-slash" - size="16" - /> - - This issue is confidential - - </div> - </div> -</div> -`; - -exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = true 1`] = ` -<div - class="block issuable-sidebar-item confidentiality" - iid="" -> - <div - class="sidebar-collapsed-icon" - title="Confidential" - > - <gl-icon-stub - name="eye-slash" - size="16" - /> - </div> - - <div - class="title hide-collapsed" - > - - Confidentiality - - <a - class="float-right confidential-edit" - data-track-event="click_edit_button" - data-track-label="right_sidebar" - data-track-property="confidentiality" - href="#" - > - Edit - </a> - </div> - - <div - class="value sidebar-item-value hide-collapsed" - > - <!----> - - <div - class="value sidebar-item-value hide-collapsed" - > - <gl-icon-stub - class="sidebar-item-icon inline is-active" - name="eye-slash" - size="16" - /> - - This issue is confidential - - </div> - </div> -</div> -`; diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js new file mode 100644 index 00000000000..8844e1626cd --- /dev/null +++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js @@ -0,0 +1,71 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import SidebarConfidentialityContent from '~/sidebar/components/confidential/sidebar_confidentiality_content.vue'; + +describe('Sidebar Confidentiality Content', () => { + let wrapper; + + const findIcon = () => wrapper.findComponent(GlIcon); + const findText = () => wrapper.find('[data-testid="confidential-text"]'); + const findCollapsedIcon = () => wrapper.find('[data-testid="sidebar-collapsed-icon"]'); + + const createComponent = ({ confidential = false, issuableType = 'issue' } = {}) => { + wrapper = shallowMount(SidebarConfidentialityContent, { + propsData: { + confidential, + issuableType, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('emits `expandSidebar` event on collapsed icon click', () => { + createComponent(); + findCollapsedIcon().trigger('click'); + + expect(wrapper.emitted('expandSidebar')).toHaveLength(1); + }); + + describe('when issue is non-confidential', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders a non-confidential icon', () => { + expect(findIcon().props('name')).toBe('eye'); + }); + + it('does not add `is-active` class to the icon', () => { + expect(findIcon().classes()).not.toContain('is-active'); + }); + + it('displays a non-confidential text', () => { + expect(findText().text()).toBe('Not confidential'); + }); + }); + + describe('when issue is confidential', () => { + it('renders a confidential icon', () => { + createComponent({ confidential: true }); + expect(findIcon().props('name')).toBe('eye-slash'); + }); + + it('adds `is-active` class to the icon', () => { + createComponent({ confidential: true }); + expect(findIcon().classes()).toContain('is-active'); + }); + + it('displays a correct confidential text for issue', () => { + createComponent({ confidential: true }); + expect(findText().text()).toBe('This issue is confidential'); + }); + + it('displays a correct confidential text for epic', () => { + createComponent({ confidential: true, issuableType: 'epic' }); + expect(findText().text()).toBe('This epic is confidential'); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js new file mode 100644 index 00000000000..d5e6310ed38 --- /dev/null +++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js @@ -0,0 +1,173 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import SidebarConfidentialityForm from '~/sidebar/components/confidential/sidebar_confidentiality_form.vue'; +import { confidentialityQueries } from '~/sidebar/constants'; + +jest.mock('~/flash'); + +describe('Sidebar Confidentiality Form', () => { + let wrapper; + + const findWarningMessage = () => wrapper.find(`[data-testid="warning-message"]`); + const findConfidentialToggle = () => wrapper.find(`[data-testid="confidential-toggle"]`); + const findCancelButton = () => wrapper.find(`[data-testid="confidential-cancel"]`); + + const createComponent = ({ + props = {}, + mutate = jest.fn().mockResolvedValue('Success'), + } = {}) => { + wrapper = shallowMount(SidebarConfidentialityForm, { + provide: { + fullPath: 'group/project', + iid: '1', + }, + propsData: { + confidential: false, + issuableType: 'issue', + ...props, + }, + mocks: { + $apollo: { + mutate, + }, + }, + stubs: { + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('emits a `closeForm` event when Cancel button is clicked', () => { + createComponent(); + findCancelButton().vm.$emit('click'); + + expect(wrapper.emitted().closeForm).toHaveLength(1); + }); + + it('renders a loading state after clicking on turn on/off button', async () => { + createComponent(); + findConfidentialToggle().vm.$emit('click', new MouseEvent('click')); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled(); + await nextTick(); + expect(findConfidentialToggle().props('loading')).toBe(true); + }); + + it('creates a flash if mutation is rejected', async () => { + createComponent({ mutate: jest.fn().mockRejectedValue('Error!') }); + findConfidentialToggle().vm.$emit('click', new MouseEvent('click')); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'Something went wrong while setting issue confidentiality.', + }); + }); + + it('creates a flash if mutation contains errors', async () => { + createComponent({ + mutate: jest.fn().mockResolvedValue({ + data: { issuableSetConfidential: { errors: ['Houston, we have a problem!'] } }, + }), + }); + findConfidentialToggle().vm.$emit('click', new MouseEvent('click')); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'Houston, we have a problem!', + }); + }); + + describe('when issue is not confidential', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders a message about making an issue confidential', () => { + expect(findWarningMessage().text()).toBe( + 'You are going to turn on confidentiality. Only team members with at least Reporter access will be able to see and leave comments on the issue.', + ); + }); + + it('has a `Turn on` button text', () => { + expect(findConfidentialToggle().text()).toBe('Turn on'); + }); + + it('calls a mutation to set confidential to true on button click', () => { + findConfidentialToggle().vm.$emit('click', new MouseEvent('click')); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: confidentialityQueries[wrapper.vm.issuableType].mutation, + variables: { + input: { + confidential: true, + iid: '1', + projectPath: 'group/project', + }, + }, + }); + }); + }); + + describe('when issue is confidential', () => { + beforeEach(() => { + createComponent({ props: { confidential: true } }); + }); + + it('renders a message about making an issue non-confidential', () => { + expect(findWarningMessage().text()).toBe( + 'You are going to turn off the confidentiality. This means everyone will be able to see and leave a comment on this issue.', + ); + }); + + it('has a `Turn off` button text', () => { + expect(findConfidentialToggle().text()).toBe('Turn off'); + }); + + it('calls a mutation to set confidential to false on button click', () => { + findConfidentialToggle().vm.$emit('click', new MouseEvent('click')); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: confidentialityQueries[wrapper.vm.issuableType].mutation, + variables: { + input: { + confidential: false, + iid: '1', + projectPath: 'group/project', + }, + }, + }); + }); + }); + + describe('when issuable type is `epic`', () => { + beforeEach(() => { + createComponent({ props: { confidential: true, issuableType: 'epic' } }); + }); + + it('renders a message about making an epic non-confidential', () => { + expect(findWarningMessage().text()).toBe( + 'You are going to turn off the confidentiality. This means everyone will be able to see and leave a comment on this epic.', + ); + }); + + it('calls a mutation to set epic confidentiality with correct parameters', () => { + findConfidentialToggle().vm.$emit('click', new MouseEvent('click')); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: confidentialityQueries[wrapper.vm.issuableType].mutation, + variables: { + input: { + confidential: false, + iid: '1', + groupPath: 'group/project', + }, + }, + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js new file mode 100644 index 00000000000..20a5be9b518 --- /dev/null +++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js @@ -0,0 +1,159 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import SidebarConfidentialityContent from '~/sidebar/components/confidential/sidebar_confidentiality_content.vue'; +import SidebarConfidentialityForm from '~/sidebar/components/confidential/sidebar_confidentiality_form.vue'; +import SidebarConfidentialityWidget, { + confidentialWidget, +} from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; +import { issueConfidentialityResponse } from '../../mock_data'; + +jest.mock('~/flash'); + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('Sidebar Confidentiality Widget', () => { + let wrapper; + let fakeApollo; + + const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findConfidentialityForm = () => wrapper.findComponent(SidebarConfidentialityForm); + const findConfidentialityContent = () => wrapper.findComponent(SidebarConfidentialityContent); + + const createComponent = ({ + confidentialQueryHandler = jest.fn().mockResolvedValue(issueConfidentialityResponse()), + } = {}) => { + fakeApollo = createMockApollo([[issueConfidentialQuery, confidentialQueryHandler]]); + + wrapper = shallowMount(SidebarConfidentialityWidget, { + localVue, + apolloProvider: fakeApollo, + provide: { + fullPath: 'group/project', + iid: '1', + canUpdate: true, + }, + propsData: { + issuableType: 'issue', + }, + stubs: { + SidebarEditableItem, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + it('passes a `loading` prop as true to editable item when query is loading', () => { + createComponent(); + + expect(findEditableItem().props('loading')).toBe(true); + }); + + it('exposes a method via external observable', () => { + createComponent(); + + expect(confidentialWidget.setConfidentiality).toEqual(wrapper.vm.setConfidentiality); + }); + + describe('when issue is not confidential', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('passes a `loading` prop as false to editable item', () => { + expect(findEditableItem().props('loading')).toBe(false); + }); + + it('passes false to `confidential` prop of child components', () => { + expect(findConfidentialityForm().props('confidential')).toBe(false); + expect(findConfidentialityContent().props('confidential')).toBe(false); + }); + + it('changes confidentiality to true after setConfidentiality is called', async () => { + confidentialWidget.setConfidentiality(); + await nextTick(); + expect(findConfidentialityForm().props('confidential')).toBe(true); + expect(findConfidentialityContent().props('confidential')).toBe(true); + }); + + it('emits `confidentialityUpdated` event with a `false` payload', () => { + expect(wrapper.emitted('confidentialityUpdated')).toEqual([[false]]); + }); + }); + + describe('when issue is confidential', () => { + beforeEach(async () => { + createComponent({ + confidentialQueryHandler: jest.fn().mockResolvedValue(issueConfidentialityResponse(true)), + }); + await waitForPromises(); + }); + + it('passes a `loading` prop as false to editable item', () => { + expect(findEditableItem().props('loading')).toBe(false); + }); + + it('passes false to `confidential` prop of child components', () => { + expect(findConfidentialityForm().props('confidential')).toBe(true); + expect(findConfidentialityContent().props('confidential')).toBe(true); + }); + + it('changes confidentiality to false after setConfidentiality is called', async () => { + confidentialWidget.setConfidentiality(); + await nextTick(); + expect(findConfidentialityForm().props('confidential')).toBe(false); + expect(findConfidentialityContent().props('confidential')).toBe(false); + }); + + it('emits `confidentialityUpdated` event with a `true` payload', () => { + expect(wrapper.emitted('confidentialityUpdated')).toEqual([[true]]); + }); + }); + + it('displays a flash message when query is rejected', async () => { + createComponent({ + confidentialQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'), + }); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalled(); + }); + + it('closes the form and dispatches an event when `closeForm` is emitted', async () => { + createComponent(); + const el = wrapper.vm.$el; + jest.spyOn(el, 'dispatchEvent'); + + await waitForPromises(); + wrapper.vm.$refs.editable.expand(); + await nextTick(); + + expect(findConfidentialityForm().isVisible()).toBe(true); + + findConfidentialityForm().vm.$emit('closeForm'); + await nextTick(); + expect(findConfidentialityForm().isVisible()).toBe(false); + + expect(el.dispatchEvent).toHaveBeenCalled(); + expect(wrapper.emitted('closeForm')).toHaveLength(1); + }); + + it('emits `expandSidebar` event when it is emitted from child component', async () => { + createComponent(); + await waitForPromises(); + findConfidentialityContent().vm.$emit('expandSidebar'); + + expect(wrapper.emitted('expandSidebar')).toHaveLength(1); + }); +}); diff --git a/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js b/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js new file mode 100644 index 00000000000..1dbb7702a15 --- /dev/null +++ b/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js @@ -0,0 +1,93 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { IssuableType } from '~/issue_show/constants'; +import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; +import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql'; +import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { issueReferenceResponse } from '../../mock_data'; + +describe('Sidebar Reference Widget', () => { + let wrapper; + let fakeApollo; + const referenceText = 'reference'; + + const createComponent = ({ + issuableType, + referenceQuery = issueReferenceQuery, + referenceQueryHandler = jest.fn().mockResolvedValue(issueReferenceResponse(referenceText)), + } = {}) => { + Vue.use(VueApollo); + + fakeApollo = createMockApollo([[referenceQuery, referenceQueryHandler]]); + + wrapper = shallowMount(SidebarReferenceWidget, { + apolloProvider: fakeApollo, + provide: { + fullPath: 'group/project', + iid: '1', + }, + propsData: { + issuableType, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe.each([ + [IssuableType.Issue, issueReferenceQuery], + [IssuableType.MergeRequest, mergeRequestReferenceQuery], + ])('when issuableType is %s', (issuableType, referenceQuery) => { + it('displays the reference text', async () => { + createComponent({ + issuableType, + referenceQuery, + }); + + await waitForPromises(); + + expect(wrapper.text()).toContain(referenceText); + }); + + it('displays loading icon while fetching and hides clipboard icon', async () => { + createComponent({ + issuableType, + referenceQuery, + }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.find(ClipboardButton).exists()).toBe(false); + }); + + it('calls createFlash with correct parameters', async () => { + const mockError = new Error('mayday'); + + createComponent({ + issuableType, + referenceQuery, + referenceQueryHandler: jest.fn().mockRejectedValue(mockError), + }); + + await waitForPromises(); + + const [ + [ + { + message, + error: { networkError }, + }, + ], + ] = wrapper.emitted('fetch-error'); + expect(message).toBe('An error occurred while fetching reference'); + expect(networkError).toEqual(mockError); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js index 7c67149b517..9f6878db785 100644 --- a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js +++ b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js @@ -7,6 +7,8 @@ import userDataMock from '../../user_data_mock'; describe('UncollapsedReviewerList component', () => { let wrapper; + const reviewerApprovalIcons = () => wrapper.findAll('[data-testid="re-approved"]'); + function createComponent(props = {}) { const propsData = { users: [], @@ -58,19 +60,29 @@ describe('UncollapsedReviewerList component', () => { const user = userDataMock(); createComponent({ - users: [user, { ...user, id: 2, username: 'hello-world' }], + users: [user, { ...user, id: 2, username: 'hello-world', approved: true }], }); }); - it('only has one user', () => { + it('has both users', () => { expect(wrapper.findAll(ReviewerAvatarLink).length).toBe(2); }); - it('shows one user with avatar, username and author name', () => { + it('shows both users with avatar, username and author name', () => { expect(wrapper.text()).toContain(`@root`); expect(wrapper.text()).toContain(`@hello-world`); }); + it('renders approval icon', () => { + expect(reviewerApprovalIcons().length).toBe(1); + }); + + it('shows that hello-world approved', () => { + const icon = reviewerApprovalIcons().at(0); + + expect(icon.attributes('title')).toEqual('Approved by @hello-world'); + }); + it('renders re-request loading icon', async () => { await wrapper.setData({ loadingStates: { 2: 'loading' } }); diff --git a/spec/frontend/sidebar/confidential/__snapshots__/edit_form_spec.js.snap b/spec/frontend/sidebar/confidential/__snapshots__/edit_form_spec.js.snap deleted file mode 100644 index d33f6c7f389..00000000000 --- a/spec/frontend/sidebar/confidential/__snapshots__/edit_form_spec.js.snap +++ /dev/null @@ -1,50 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Edit Form Dropdown when confidential renders on or off text based on confidentiality 1`] = ` -<div - class="dropdown show" - toggleform="function () {}" - updateconfidentialattribute="function () {}" -> - <div - class="dropdown-menu sidebar-item-warning-message" - > - <div> - <p> - <gl-sprintf-stub - message="You are going to turn off the confidentiality. This means %{strongStart}everyone%{strongEnd} will be able to see and leave a comment on this %{issuableType}." - /> - </p> - - <edit-form-buttons-stub - confidential="true" - fullpath="" - /> - </div> - </div> -</div> -`; - -exports[`Edit Form Dropdown when not confidential renders "You are going to turn on the confidentiality." in the 1`] = ` -<div - class="dropdown show" - toggleform="function () {}" - updateconfidentialattribute="function () {}" -> - <div - class="dropdown-menu sidebar-item-warning-message" - > - <div> - <p> - <gl-sprintf-stub - message="You are going to turn on the confidentiality. This means that only team members with %{strongStart}at least Reporter access%{strongEnd} are able to see and leave comments on the %{issuableType}." - /> - </p> - - <edit-form-buttons-stub - fullpath="" - /> - </div> - </div> -</div> -`; diff --git a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js deleted file mode 100644 index 427e3a89c29..00000000000 --- a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js +++ /dev/null @@ -1,146 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import waitForPromises from 'helpers/wait_for_promises'; -import { deprecatedCreateFlash as flash } from '~/flash'; -import createStore from '~/notes/stores'; -import EditFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue'; -import eventHub from '~/sidebar/event_hub'; - -jest.mock('~/sidebar/event_hub', () => ({ $emit: jest.fn() })); -jest.mock('~/flash'); - -describe('Edit Form Buttons', () => { - let wrapper; - let store; - const findConfidentialToggle = () => wrapper.find('[data-testid="confidential-toggle"]'); - - const createComponent = ({ props = {}, data = {}, resolved = true }) => { - store = createStore(); - if (resolved) { - jest.spyOn(store, 'dispatch').mockResolvedValue(); - } else { - jest.spyOn(store, 'dispatch').mockRejectedValue(); - } - - wrapper = shallowMount(EditFormButtons, { - propsData: { - fullPath: '', - ...props, - }, - data() { - return { - isLoading: true, - ...data, - }; - }, - store, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('when isLoading', () => { - beforeEach(() => { - createComponent({ - props: { - confidential: false, - }, - }); - }); - - it('renders "Applying" in the toggle button', () => { - expect(findConfidentialToggle().text()).toBe('Applying'); - }); - - it('disables the toggle button', () => { - expect(findConfidentialToggle().props('disabled')).toBe(true); - }); - - it('sets loading on the toggle button', () => { - expect(findConfidentialToggle().props('loading')).toBe(true); - }); - }); - - describe('when not confidential', () => { - it('renders Turn On in the toggle button', () => { - createComponent({ - data: { - isLoading: false, - }, - props: { - confidential: false, - }, - }); - - expect(findConfidentialToggle().text()).toBe('Turn On'); - }); - }); - - describe('when confidential', () => { - beforeEach(() => { - createComponent({ - data: { - isLoading: false, - }, - props: { - confidential: true, - }, - }); - }); - - it('renders on or off text based on confidentiality', () => { - expect(findConfidentialToggle().text()).toBe('Turn Off'); - }); - }); - - describe('when succeeds', () => { - beforeEach(() => { - createComponent({ data: { isLoading: false }, props: { confidential: true } }); - findConfidentialToggle().vm.$emit('click', new Event('click')); - }); - - it('dispatches the correct action', () => { - expect(store.dispatch).toHaveBeenCalledWith('updateConfidentialityOnIssuable', { - confidential: false, - fullPath: '', - }); - }); - - it('resets loading on the toggle button', () => { - return waitForPromises().then(() => { - expect(findConfidentialToggle().props('loading')).toBe(false); - }); - }); - - it('emits close form', () => { - return waitForPromises().then(() => { - expect(eventHub.$emit).toHaveBeenCalledWith('closeConfidentialityForm'); - }); - }); - - it('emits updateOnConfidentiality event', () => { - return waitForPromises().then(() => { - expect(eventHub.$emit).toHaveBeenCalledWith('updateIssuableConfidentiality', false); - }); - }); - }); - - describe('when fails', () => { - beforeEach(() => { - createComponent({ - data: { isLoading: false }, - props: { confidential: true }, - resolved: false, - }); - findConfidentialToggle().vm.$emit('click', new Event('click')); - }); - - it('calls flash with the correct message', () => { - expect(flash).toHaveBeenCalledWith( - 'Something went wrong trying to change the confidentiality of this issue', - ); - }); - }); -}); diff --git a/spec/frontend/sidebar/confidential/edit_form_spec.js b/spec/frontend/sidebar/confidential/edit_form_spec.js deleted file mode 100644 index 6b571df10ae..00000000000 --- a/spec/frontend/sidebar/confidential/edit_form_spec.js +++ /dev/null @@ -1,48 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import EditForm from '~/sidebar/components/confidential/edit_form.vue'; - -describe('Edit Form Dropdown', () => { - let wrapper; - const toggleForm = () => {}; - const updateConfidentialAttribute = () => {}; - - const createComponent = (props) => { - wrapper = shallowMount(EditForm, { - propsData: { - ...props, - isLoading: false, - fullPath: '', - issuableType: 'issue', - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('when not confidential', () => { - it('renders "You are going to turn on the confidentiality." in the ', () => { - createComponent({ - confidential: false, - toggleForm, - updateConfidentialAttribute, - }); - - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - describe('when confidential', () => { - it('renders on or off text based on confidentiality', () => { - createComponent({ - confidential: true, - toggleForm, - updateConfidentialAttribute, - }); - - expect(wrapper.element).toMatchSnapshot(); - }); - }); -}); diff --git a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js deleted file mode 100644 index 93a6401b1fc..00000000000 --- a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js +++ /dev/null @@ -1,159 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; -import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; -import createStore from '~/notes/stores'; -import * as types from '~/notes/stores/mutation_types'; -import ConfidentialIssueSidebar from '~/sidebar/components/confidential/confidential_issue_sidebar.vue'; -import EditForm from '~/sidebar/components/confidential/edit_form.vue'; - -jest.mock('~/flash'); -jest.mock('~/sidebar/services/sidebar_service'); - -describe('Confidential Issue Sidebar Block', () => { - useMockLocationHelper(); - - let wrapper; - const mutate = jest - .fn() - .mockResolvedValue({ data: { issueSetConfidential: { issue: { confidential: true } } } }); - - const createComponent = ({ propsData, data = {} }) => { - const store = createStore(); - wrapper = shallowMount(ConfidentialIssueSidebar, { - store, - data() { - return data; - }, - propsData: { - iid: '', - fullPath: '', - ...propsData, - }, - mocks: { - $apollo: { - mutate, - }, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it.each` - confidential | isEditable - ${false} | ${false} - ${false} | ${true} - ${true} | ${false} - ${true} | ${true} - `( - 'renders for confidential = $confidential and isEditable = $isEditable', - ({ confidential, isEditable }) => { - createComponent({ - propsData: { - isEditable, - }, - }); - wrapper.vm.$store.state.noteableData.confidential = confidential; - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }, - ); - - describe('if editable', () => { - beforeEach(() => { - createComponent({ - propsData: { - isEditable: true, - }, - }); - wrapper.vm.$store.state.noteableData.confidential = true; - }); - - it('displays the edit form when editable', () => { - wrapper.setData({ edit: false }); - - return wrapper.vm - .$nextTick() - .then(() => { - wrapper.find({ ref: 'editLink' }).trigger('click'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(wrapper.find(EditForm).exists()).toBe(true); - }); - }); - - it('displays the edit form when opened from collapsed state', () => { - wrapper.setData({ edit: false }); - - return wrapper.vm - .$nextTick() - .then(() => { - wrapper.find({ ref: 'collapseIcon' }).trigger('click'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(wrapper.find(EditForm).exists()).toBe(true); - }); - }); - - it('tracks the event when "Edit" is clicked', () => { - const spy = mockTracking('_category_', wrapper.element, jest.spyOn); - - const editLink = wrapper.find({ ref: 'editLink' }); - triggerEvent(editLink.element); - - expect(spy).toHaveBeenCalledWith('_category_', 'click_edit_button', { - label: 'right_sidebar', - property: 'confidentiality', - }); - }); - }); - describe('computed confidential', () => { - beforeEach(() => { - createComponent({ - propsData: { - isEditable: true, - }, - }); - }); - - it('returns false when noteableData is not present', () => { - wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, null); - - expect(wrapper.vm.confidential).toBe(false); - }); - - it('returns true when noteableData has confidential attr as true', () => { - wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, {}); - wrapper.vm.$store.commit(types.SET_ISSUE_CONFIDENTIAL, true); - - expect(wrapper.vm.confidential).toBe(true); - }); - - it('returns false when noteableData has confidential attr as false', () => { - wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, {}); - wrapper.vm.$store.commit(types.SET_ISSUE_CONFIDENTIAL, false); - - expect(wrapper.vm.confidential).toBe(false); - }); - - it('returns true when confidential attr is true', () => { - wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, {}); - wrapper.vm.$store.commit(types.SET_ISSUE_CONFIDENTIAL, true); - - expect(wrapper.vm.confidential).toBe(true); - }); - - it('returns false when confidential attr is false', () => { - wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, {}); - wrapper.vm.$store.commit(types.SET_ISSUE_CONFIDENTIAL, false); - - expect(wrapper.vm.confidential).toBe(false); - }); - }); -}); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index 3dde40260eb..e751f1239c8 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -220,4 +220,29 @@ const mockData = { }, }; +export const issueConfidentialityResponse = (confidential = false) => ({ + data: { + workspace: { + __typename: 'Project', + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/4', + confidential, + }, + }, + }, +}); + +export const issueReferenceResponse = (reference) => ({ + data: { + workspace: { + __typename: 'Project', + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/4', + reference, + }, + }, + }, +}); export default mockData; diff --git a/spec/frontend/sidebar/subscriptions_spec.js b/spec/frontend/sidebar/subscriptions_spec.js index e7ae59e26cf..6ab8e1e0ebc 100644 --- a/spec/frontend/sidebar/subscriptions_spec.js +++ b/spec/frontend/sidebar/subscriptions_spec.js @@ -84,6 +84,15 @@ describe('Subscriptions', () => { spy.mockRestore(); }); + it('has visually hidden label', () => { + wrapper = mountComponent(); + + expect(findToggleButton().props()).toMatchObject({ + label: 'Notifications', + labelPosition: 'hidden', + }); + }); + describe('given project emails are disabled', () => { const subscribeDisabledDescription = 'Notifications have been disabled'; diff --git a/spec/frontend/sidebar/user_data_mock.js b/spec/frontend/sidebar/user_data_mock.js index 41d0331f34a..7c11551b0be 100644 --- a/spec/frontend/sidebar/user_data_mock.js +++ b/spec/frontend/sidebar/user_data_mock.js @@ -10,4 +10,5 @@ export default () => ({ can_merge: true, can_update_merge_request: true, reviewed: true, + approved: false, }); diff --git a/spec/frontend/single_file_diff_spec.js b/spec/frontend/single_file_diff_spec.js new file mode 100644 index 00000000000..8718152655f --- /dev/null +++ b/spec/frontend/single_file_diff_spec.js @@ -0,0 +1,96 @@ +import MockAdapter from 'axios-mock-adapter'; +import $ from 'jquery'; +import { setHTMLFixture } from 'helpers/fixtures'; +import axios from '~/lib/utils/axios_utils'; +import SingleFileDiff from '~/single_file_diff'; + +describe('SingleFileDiff', () => { + let mock = new MockAdapter(axios); + const blobDiffPath = '/mock-path'; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(blobDiffPath).replyOnce(200, { html: `<div class="diff-content">MOCKED</div>` }); + }); + + afterEach(() => { + mock.restore(); + }); + + it('loads diff via axios exactly once for collapsed diffs', async () => { + setHTMLFixture(` + <div class="diff-file"> + <div class="js-file-title"> + MOCK TITLE + </div> + + <div class="diff-content"> + <div class="diff-viewer" data-type="simple"> + <div + class="nothing-here-block diff-collapsed" + data-diff-for-path="${blobDiffPath}" + > + MOCK CONTENT + </div> + </div> + </div> + </div> +`); + + // Collapsed is the default state + const diff = new SingleFileDiff(document.querySelector('.diff-file')); + expect(diff.isOpen).toBe(false); + expect(diff.content).toBeNull(); + expect(diff.diffForPath).toEqual(blobDiffPath); + + // Opening for the first time + await diff.toggleDiff($(document.querySelector('.js-file-title'))); + expect(diff.isOpen).toBe(true); + expect(diff.content).not.toBeNull(); + + // Collapsing again + await diff.toggleDiff($(document.querySelector('.js-file-title'))); + expect(diff.isOpen).toBe(false); + expect(diff.content).not.toBeNull(); + + mock.onGet(blobDiffPath).replyOnce(400, ''); + + // Opening again + await diff.toggleDiff($(document.querySelector('.js-file-title'))); + expect(diff.isOpen).toBe(true); + expect(diff.content).not.toBeNull(); + + expect(mock.history.get.length).toBe(1); + }); + + it('does not load diffs via axios for already expanded diffs', async () => { + setHTMLFixture(` + <div class="diff-file"> + <div class="js-file-title"> + MOCK TITLE + </div> + + <div class="diff-content"> + EXPANDED MOCK CONTENT + </div> + </div> +`); + + // Opened is the default state + const diff = new SingleFileDiff(document.querySelector('.diff-file')); + expect(diff.isOpen).toBe(true); + expect(diff.content).not.toBeNull(); + expect(diff.diffForPath).toEqual(undefined); + + // Collapsing for the first time + await diff.toggleDiff($(document.querySelector('.js-file-title'))); + expect(diff.isOpen).toBe(false); + expect(diff.content).not.toBeNull(); + + // Opening again + await diff.toggleDiff($(document.querySelector('.js-file-title'))); + expect(diff.isOpen).toBe(true); + + expect(mock.history.get.length).toBe(0); + }); +}); diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap index 8446f0f50c4..95da67c2bbf 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap @@ -46,6 +46,8 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = <span class="font-weight-bold ml-1 js-visibility-option" + data-qa-selector="visibility_content" + data-qa-visibility="Private" > Private </span> @@ -65,6 +67,8 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = <span class="font-weight-bold ml-1 js-visibility-option" + data-qa-selector="visibility_content" + data-qa-visibility="Internal" > Internal </span> @@ -84,6 +88,8 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = <span class="font-weight-bold ml-1 js-visibility-option" + data-qa-selector="visibility_content" + data-qa-visibility="Public" > Public </span> diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js index b6b29faef79..9b95ed6b816 100644 --- a/spec/frontend/test_setup.js +++ b/spec/frontend/test_setup.js @@ -44,11 +44,6 @@ Object.assign(global, { getJSONFixture, loadFixtures: loadHTMLFixture, setFixtures: setHTMLFixture, - - // The following functions fill the fixtures cache in Karma. - // This is not necessary in Jest because we make no Ajax request. - loadJSONFixtures() {}, - preloadFixtures() {}, }); // custom-jquery-matchers was written for an old Jest version, we need to make it compatible diff --git a/spec/frontend/tooltips/components/tooltips_spec.js b/spec/frontend/tooltips/components/tooltips_spec.js index e21626456e2..c44918ceaf3 100644 --- a/spec/frontend/tooltips/components/tooltips_spec.js +++ b/spec/frontend/tooltips/components/tooltips_spec.js @@ -217,4 +217,14 @@ describe('tooltips/components/tooltips.vue', () => { wrapper.destroy(); expect(observersCount()).toBe(0); }); + + it('exposes hidden event', async () => { + buildWrapper(); + wrapper.vm.addTooltips([createTooltipTarget()]); + + await wrapper.vm.$nextTick(); + + wrapper.findComponent(GlTooltip).vm.$emit('hidden'); + expect(wrapper.emitted('hidden')).toHaveLength(1); + }); }); diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js index a516a4a8269..6a22de3be5c 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking_spec.js @@ -1,5 +1,9 @@ import { setHTMLFixture } from 'helpers/fixtures'; -import Tracking, { initUserTracking, initDefaultTrackers } from '~/tracking'; +import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; +import { getExperimentData } from '~/experimentation/utils'; +import Tracking, { initUserTracking, initDefaultTrackers, STANDARD_CONTEXT } from '~/tracking'; + +jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() })); describe('Tracking', () => { let snowplowSpy; @@ -7,6 +11,8 @@ describe('Tracking', () => { let trackLoadEventsSpy; beforeEach(() => { + getExperimentData.mockReturnValue(undefined); + window.snowplow = window.snowplow || (() => {}); window.snowplowOptions = { namespace: '_namespace_', @@ -45,7 +51,7 @@ describe('Tracking', () => { it('should activate features based on what has been enabled', () => { initDefaultTrackers(); expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', 30, 30); - expect(snowplowSpy).toHaveBeenCalledWith('trackPageView'); + expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [STANDARD_CONTEXT]); expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking'); expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking'); @@ -78,6 +84,34 @@ describe('Tracking', () => { navigator.msDoNotTrack = undefined; }); + describe('builds the standard context', () => { + let standardContext; + + beforeAll(async () => { + window.gl = window.gl || {}; + window.gl.snowplowStandardContext = { + schema: 'iglu:com.gitlab/gitlab_standard', + data: { + environment: 'testing', + source: 'unknown', + }, + }; + + jest.resetModules(); + + ({ STANDARD_CONTEXT: standardContext } = await import('~/tracking')); + }); + + it('uses server data', () => { + expect(standardContext.schema).toBe('iglu:com.gitlab/gitlab_standard'); + expect(standardContext.data.environment).toBe('testing'); + }); + + it('overrides schema source', () => { + expect(standardContext.data.source).toBe('gitlab-javascript'); + }); + }); + it('tracks to snowplow (our current tracking system)', () => { Tracking.event('_category_', '_eventName_', { label: '_label_' }); @@ -88,7 +122,7 @@ describe('Tracking', () => { '_label_', undefined, undefined, - undefined, + [STANDARD_CONTEXT], ); }); @@ -121,6 +155,27 @@ describe('Tracking', () => { }); }); + describe('.flushPendingEvents', () => { + it('flushes any pending events', () => { + Tracking.initialized = false; + Tracking.event('_category_', '_eventName_', { label: '_label_' }); + + expect(snowplowSpy).not.toHaveBeenCalled(); + + Tracking.flushPendingEvents(); + + expect(snowplowSpy).toHaveBeenCalledWith( + 'trackStructEvent', + '_category_', + '_eventName_', + '_label_', + undefined, + undefined, + [STANDARD_CONTEXT], + ); + }); + }); + describe('tracking interface events', () => { let eventSpy; @@ -134,6 +189,7 @@ describe('Tracking', () => { <input class="dropdown" data-track-event="toggle_dropdown"/> <div data-track-event="nested_event"><span class="nested"></span></div> <input data-track-eventbogus="click_bogusinput" data-track-label="_label_" value="_value_"/> + <input data-track-event="click_input3" data-track-experiment="example" value="_value_"/> `); }); @@ -193,6 +249,22 @@ describe('Tracking', () => { expect(eventSpy).toHaveBeenCalledWith('_category_', 'nested_event', {}); }); + + it('brings in experiment data if linked to an experiment', () => { + const mockExperimentData = { + variant: 'candidate', + experiment: 'repo_integrations_link', + key: '2bff73f6bb8cc11156c50a8ba66b9b8b', + }; + getExperimentData.mockReturnValue(mockExperimentData); + + document.querySelector('[data-track-event="click_input3"]').click(); + + expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input3', { + value: '_value_', + context: { schema: TRACKING_CONTEXT_SCHEMA, data: mockExperimentData }, + }); + }); }); describe('tracking page loaded events', () => { @@ -235,21 +307,21 @@ describe('Tracking', () => { describe('tracking mixin', () => { describe('trackingOptions', () => { - it('return the options defined on initialisation', () => { + it('returns the options defined on initialisation', () => { const mixin = Tracking.mixin({ foo: 'bar' }); expect(mixin.computed.trackingOptions()).toEqual({ foo: 'bar' }); }); - it('local tracking value override and extend options', () => { + it('lets local tracking value override and extend options', () => { const mixin = Tracking.mixin({ foo: 'bar' }); - // the value of this in the vue lifecyle is different, but this serve the tests purposes + // The value of this in the Vue lifecyle is different, but this serves the test's purposes mixin.computed.tracking = { foo: 'baz', baz: 'bar' }; expect(mixin.computed.trackingOptions()).toEqual({ foo: 'baz', baz: 'bar' }); }); }); describe('trackingCategory', () => { - it('return the category set in the component properties first', () => { + it('returns the category set in the component properties first', () => { const mixin = Tracking.mixin({ category: 'foo' }); mixin.computed.tracking = { category: 'bar', @@ -257,12 +329,12 @@ describe('Tracking', () => { expect(mixin.computed.trackingCategory()).toBe('bar'); }); - it('return the category set in the options', () => { + it('returns the category set in the options', () => { const mixin = Tracking.mixin({ category: 'foo' }); expect(mixin.computed.trackingCategory()).toBe('foo'); }); - it('if no category is selected returns undefined', () => { + it('returns undefined if no category is selected', () => { const mixin = Tracking.mixin(); expect(mixin.computed.trackingCategory()).toBe(undefined); }); @@ -297,7 +369,7 @@ describe('Tracking', () => { expect(eventSpy).toHaveBeenCalledWith(undefined, 'foo', {}); }); - it('give precedence to data for category and options', () => { + it('gives precedence to data for category and options', () => { mixin.trackingCategory = mixin.trackingCategory(); mixin.trackingOptions = mixin.trackingOptions(); const data = { category: 'foo', label: 'baz' }; diff --git a/spec/frontend/user_popovers_spec.js b/spec/frontend/user_popovers_spec.js index 7c9c3d69efa..745b66fd700 100644 --- a/spec/frontend/user_popovers_spec.js +++ b/spec/frontend/user_popovers_spec.js @@ -3,9 +3,21 @@ import initUserPopovers from '~/user_popovers'; describe('User Popovers', () => { const fixtureTemplate = 'merge_requests/merge_request_with_mentions.html'; - preloadFixtures(fixtureTemplate); const selector = '.js-user-link, .gfm-project_member'; + const findFixtureLinks = () => { + return Array.from(document.querySelectorAll(selector)).filter( + ({ dataset }) => dataset.user || dataset.userId, + ); + }; + const createUserLink = () => { + const link = document.createElement('a'); + + link.classList.add('js-user-link'); + link.setAttribute('data-user', '1'); + + return link; + }; const dummyUser = { name: 'root' }; const dummyUserStatus = { message: 'active' }; @@ -37,13 +49,20 @@ describe('User Popovers', () => { }); it('initializes a popover for each user link with a user id', () => { - const linksWithUsers = Array.from(document.querySelectorAll(selector)).filter( - ({ dataset }) => dataset.user || dataset.userId, - ); + const linksWithUsers = findFixtureLinks(); expect(linksWithUsers.length).toBe(popovers.length); }); + it('adds popovers to user links added to the DOM tree after the initial call', async () => { + document.body.appendChild(createUserLink()); + document.body.appendChild(createUserLink()); + + const linksWithUsers = findFixtureLinks(); + + expect(linksWithUsers.length).toBe(popovers.length + 2); + }); + it('does not initialize the user popovers twice for the same element', () => { const newPopovers = initUserPopovers(document.querySelectorAll(selector)); const samePopovers = popovers.every((popover, index) => newPopovers[index] === popover); diff --git a/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js b/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js index b2cc7d9be6b..e2386bc7f2b 100644 --- a/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js +++ b/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js @@ -48,7 +48,7 @@ describe('Merge Requests Artifacts list app', () => { }; const findButtons = () => wrapper.findAll('button'); - const findTitle = () => wrapper.find('.js-title'); + const findTitle = () => wrapper.find('[data-testid="mr-collapsible-title"]'); const findErrorMessage = () => wrapper.find('.js-error-state'); const findTableRows = () => wrapper.findAll('tbody tr'); diff --git a/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js index 94d4cccab5f..1aeb080aa04 100644 --- a/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js @@ -1,4 +1,4 @@ -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MrCollapsibleSection from '~/vue_merge_request_widget/components/mr_collapsible_extension.vue'; @@ -15,12 +15,14 @@ describe('Merge Request Collapsible Extension', () => { }, slots: { default: '<div class="js-slot">Foo</div>', + header: '<span data-testid="collapsed-header">hello there</span>', }, }); }; - const findTitle = () => wrapper.find('.js-title'); + const findTitle = () => wrapper.find('[data-testid="mr-collapsible-title"]'); const findErrorMessage = () => wrapper.find('.js-error-state'); + const findIcon = () => wrapper.find(GlIcon); afterEach(() => { wrapper.destroy(); @@ -35,8 +37,12 @@ describe('Merge Request Collapsible Extension', () => { expect(findTitle().text()).toBe(data.title); }); + it('renders the header slot', () => { + expect(wrapper.find('[data-testid="collapsed-header"]').text()).toBe('hello there'); + }); + it('renders angle-right icon', () => { - expect(wrapper.vm.arrowIconName).toBe('angle-right'); + expect(findIcon().props('name')).toBe('angle-right'); }); describe('onClick', () => { @@ -54,7 +60,7 @@ describe('Merge Request Collapsible Extension', () => { }); it('renders angle-down icon', () => { - expect(wrapper.vm.arrowIconName).toBe('angle-down'); + expect(findIcon().props('name')).toBe('angle-down'); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_merge_help_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_merge_help_spec.js deleted file mode 100644 index 53a74bf7456..00000000000 --- a/spec/frontend/vue_mr_widget/components/mr_widget_merge_help_spec.js +++ /dev/null @@ -1,46 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import MergeHelpComponent from '~/vue_merge_request_widget/components/mr_widget_merge_help.vue'; - -describe('MRWidgetMergeHelp', () => { - let wrapper; - - const createComponent = ({ props = {} } = {}) => { - wrapper = shallowMount(MergeHelpComponent, { - propsData: { - missingBranch: 'this-is-not-the-branch-you-are-looking-for', - ...props, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('with missing branch', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders missing branch information', () => { - expect(wrapper.find('.mr-widget-help').text()).toContain( - 'If the this-is-not-the-branch-you-are-looking-for branch exists in your local repository', - ); - }); - }); - - describe('without missing branch', () => { - beforeEach(() => { - createComponent({ - props: { missingBranch: '' }, - }); - }); - - it('renders information about how to merge manually', () => { - expect(wrapper.find('.mr-widget-help').text()).toContain( - 'You can merge this merge request manually', - ); - }); - }); -}); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js index 3baade5161e..5ec719b17d6 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js @@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import ArtifactsApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue'; +import DeploymentList from '~/vue_merge_request_widget/components/deployment/deployment_list.vue'; import MrWidgetPipeline from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; import MrWidgetPipelineContainer from '~/vue_merge_request_widget/components/mr_widget_pipeline_container.vue'; import { mockStore } from '../mock_data'; @@ -28,6 +29,8 @@ describe('MrWidgetPipelineContainer', () => { wrapper.destroy(); }); + const findDeploymentList = () => wrapper.findComponent(DeploymentList); + describe('when pre merge', () => { beforeEach(() => { factory(); @@ -55,6 +58,9 @@ describe('MrWidgetPipelineContainer', () => { const deployments = wrapper.findAll('.mr-widget-extension .js-pre-deployment'); + expect(findDeploymentList().exists()).toBe(true); + expect(findDeploymentList().props('deployments')).toBe(mockStore.deployments); + expect(deployments.wrappers.map((x) => x.props())).toEqual(expectedProps); }); }); @@ -100,6 +106,8 @@ describe('MrWidgetPipelineContainer', () => { const deployments = wrapper.findAll('.mr-widget-extension .js-post-deployment'); + expect(findDeploymentList().exists()).toBe(true); + expect(findDeploymentList().props('deployments')).toBe(mockStore.postMergeDeployments); expect(deployments.wrappers.map((x) => x.props())).toEqual(expectedProps); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js index b93236d4628..28492018600 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js @@ -1,7 +1,8 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; -import PipelineStage from '~/pipelines/components/pipelines_list/stage.vue'; +import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; +import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue'; import PipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; import { SUCCESS } from '~/vue_merge_request_widget/constants'; import mockData from '../mock_data'; @@ -25,7 +26,7 @@ describe('MRWidgetPipeline', () => { const findPipelineID = () => wrapper.find('[data-testid="pipeline-id"]'); const findPipelineInfoContainer = () => wrapper.find('[data-testid="pipeline-info-container"]'); const findCommitLink = () => wrapper.find('[data-testid="commit-link"]'); - const findPipelineGraph = () => wrapper.find('[data-testid="widget-mini-pipeline-graph"]'); + const findPipelineMiniGraph = () => wrapper.find(PipelineMiniGraph); const findAllPipelineStages = () => wrapper.findAll(PipelineStage); const findPipelineCoverage = () => wrapper.find('[data-testid="pipeline-coverage"]'); const findPipelineCoverageDelta = () => wrapper.find('[data-testid="pipeline-coverage-delta"]'); @@ -35,7 +36,7 @@ describe('MRWidgetPipeline', () => { wrapper.find('[data-testid="monitoring-pipeline-message"]'); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - const createWrapper = (props, mountFn = shallowMount) => { + const createWrapper = (props = {}, mountFn = shallowMount) => { wrapper = mountFn(PipelineComponent, { propsData: { ...defaultProps, @@ -65,10 +66,13 @@ describe('MRWidgetPipeline', () => { describe('with a pipeline', () => { beforeEach(() => { - createWrapper({ - pipelineCoverageDelta: mockData.pipelineCoverageDelta, - buildsWithCoverage: mockData.buildsWithCoverage, - }); + createWrapper( + { + pipelineCoverageDelta: mockData.pipelineCoverageDelta, + buildsWithCoverage: mockData.buildsWithCoverage, + }, + mount, + ); }); it('should render pipeline ID', () => { @@ -84,8 +88,8 @@ describe('MRWidgetPipeline', () => { }); it('should render pipeline graph', () => { - expect(findPipelineGraph().exists()).toBe(true); - expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length); + expect(findPipelineMiniGraph().exists()).toBe(true); + expect(findAllPipelineStages()).toHaveLength(mockData.pipeline.details.stages.length); }); describe('should render pipeline coverage information', () => { @@ -136,7 +140,7 @@ describe('MRWidgetPipeline', () => { const mockCopy = JSON.parse(JSON.stringify(mockData)); delete mockCopy.pipeline.commit; - createWrapper({}); + createWrapper({}, mount); }); it('should render pipeline ID', () => { @@ -147,9 +151,15 @@ describe('MRWidgetPipeline', () => { expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label); }); - it('should render pipeline graph', () => { - expect(findPipelineGraph().exists()).toBe(true); - expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length); + it('should render pipeline graph with correct styles', () => { + const stagesCount = mockData.pipeline.details.stages.length; + + expect(findPipelineMiniGraph().exists()).toBe(true); + expect(findPipelineMiniGraph().findAll('.mr-widget-pipeline-stages')).toHaveLength( + stagesCount, + ); + + expect(findAllPipelineStages()).toHaveLength(stagesCount); }); it('should render coverage information', () => { @@ -181,7 +191,7 @@ describe('MRWidgetPipeline', () => { }); it('should not render a pipeline graph', () => { - expect(findPipelineGraph().exists()).toBe(false); + expect(findPipelineMiniGraph().exists()).toBe(false); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js index a33401c5ba9..a879b06e858 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js @@ -1,85 +1,88 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import relatedLinksComponent from '~/vue_merge_request_widget/components/mr_widget_related_links.vue'; +import { shallowMount } from '@vue/test-utils'; +import RelatedLinks from '~/vue_merge_request_widget/components/mr_widget_related_links.vue'; describe('MRWidgetRelatedLinks', () => { - let vm; + let wrapper; - const createComponent = (data) => { - const Component = Vue.extend(relatedLinksComponent); - - return mountComponent(Component, data); + const createComponent = (propsData = {}) => { + wrapper = shallowMount(RelatedLinks, { propsData }); }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); describe('computed', () => { describe('closesText', () => { it('returns Closes text for open merge request', () => { - vm = createComponent({ state: 'open', relatedLinks: {} }); + createComponent({ state: 'open', relatedLinks: {} }); - expect(vm.closesText).toEqual('Closes'); + expect(wrapper.vm.closesText).toBe('Closes'); }); it('returns correct text for closed merge request', () => { - vm = createComponent({ state: 'closed', relatedLinks: {} }); + createComponent({ state: 'closed', relatedLinks: {} }); - expect(vm.closesText).toEqual('Did not close'); + expect(wrapper.vm.closesText).toBe('Did not close'); }); it('returns correct tense for merged request', () => { - vm = createComponent({ state: 'merged', relatedLinks: {} }); + createComponent({ state: 'merged', relatedLinks: {} }); - expect(vm.closesText).toEqual('Closed'); + expect(wrapper.vm.closesText).toBe('Closed'); }); }); }); it('should have only have closing issues text', () => { - vm = createComponent({ + createComponent({ relatedLinks: { closing: '<a href="#">#23</a> and <a>#42</a>', }, }); - const content = vm.$el.textContent.replace(/\n(\s)+/g, ' ').trim(); + const content = wrapper + .text() + .replace(/\n(\s)+/g, ' ') + .trim(); expect(content).toContain('Closes #23 and #42'); expect(content).not.toContain('Mentions'); }); it('should have only have mentioned issues text', () => { - vm = createComponent({ + createComponent({ relatedLinks: { mentioned: '<a href="#">#7</a>', }, }); - expect(vm.$el.innerText).toContain('Mentions #7'); - expect(vm.$el.innerText).not.toContain('Closes'); + expect(wrapper.text().trim()).toContain('Mentions #7'); + expect(wrapper.text().trim()).not.toContain('Closes'); }); it('should have closing and mentioned issues at the same time', () => { - vm = createComponent({ + createComponent({ relatedLinks: { closing: '<a href="#">#7</a>', mentioned: '<a href="#">#23</a> and <a>#42</a>', }, }); - const content = vm.$el.textContent.replace(/\n(\s)+/g, ' ').trim(); + const content = wrapper + .text() + .replace(/\n(\s)+/g, ' ') + .trim(); expect(content).toContain('Closes #7'); expect(content).toContain('Mentions #23 and #42'); }); it('should have assing issues link', () => { - vm = createComponent({ + createComponent({ relatedLinks: { assignToMe: '<a href="#">Assign yourself to these issues</a>', }, }); - expect(vm.$el.innerText).toContain('Assign yourself to these issues'); + expect(wrapper.text().trim()).toContain('Assign yourself to these issues'); }); }); diff --git a/spec/frontend/vue_mr_widget/components/review_app_link_spec.js b/spec/frontend/vue_mr_widget/components/review_app_link_spec.js index 81a52890db7..e393b56034d 100644 --- a/spec/frontend/vue_mr_widget/components/review_app_link_spec.js +++ b/spec/frontend/vue_mr_widget/components/review_app_link_spec.js @@ -1,10 +1,8 @@ -import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import component from '~/vue_merge_request_widget/components/review_app_link.vue'; +import ReviewAppLink from '~/vue_merge_request_widget/components/review_app_link.vue'; describe('review app link', () => { - const Component = Vue.extend(component); const props = { link: '/review', cssClass: 'js-link', @@ -13,37 +11,35 @@ describe('review app link', () => { tooltip: '', }, }; - let vm; - let el; + let wrapper; beforeEach(() => { - vm = mountComponent(Component, props); - el = vm.$el; + wrapper = shallowMount(ReviewAppLink, { propsData: props }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders provided link as href attribute', () => { - expect(el.getAttribute('href')).toEqual(props.link); + expect(wrapper.attributes('href')).toBe(props.link); }); it('renders provided cssClass as class attribute', () => { - expect(el.getAttribute('class')).toContain(props.cssClass); + expect(wrapper.classes('js-link')).toBe(true); }); it('renders View app text', () => { - expect(el.textContent.trim()).toEqual('View app'); + expect(wrapper.text().trim()).toBe('View app'); }); it('renders svg icon', () => { - expect(el.querySelector('svg')).not.toBeNull(); + expect(wrapper.find('svg')).not.toBeNull(); }); it('tracks an event when clicked', () => { - const spy = mockTracking('_category_', el, jest.spyOn); - triggerEvent(el); + const spy = mockTracking('_category_', wrapper.element, jest.spyOn); + triggerEvent(wrapper.element); expect(spy).toHaveBeenCalledWith('_category_', 'open_review_app', { label: 'review_app', diff --git a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap index c425a3a86a9..e5862df5dda 100644 --- a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap +++ b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap @@ -16,6 +16,7 @@ exports[`MRWidgetAutoMergeEnabled when graphql is disabled template should have > <span class="gl-mr-3" + data-qa-selector="merge_request_status_content" > <span class="js-status-text-before-author" @@ -107,6 +108,7 @@ exports[`MRWidgetAutoMergeEnabled when graphql is enabled template should have c > <span class="gl-mr-3" + data-qa-selector="merge_request_status_content" > <span class="js-status-text-before-author" diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js index d3fc1e0e05b..dc2f227b29c 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js @@ -1,36 +1,41 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import $ from 'jquery'; +import { GlPopover } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; import { removeBreakLine } from 'helpers/text_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import ConflictsComponent from '~/vue_merge_request_widget/components/states/mr_widget_conflicts.vue'; describe('MRWidgetConflicts', () => { - let vm; + let wrapper; let mergeRequestWidgetGraphql = null; const path = '/conflicts'; - function createComponent(propsData = {}) { - const localVue = createLocalVue(); + const findPopover = () => wrapper.find(GlPopover); + const findResolveButton = () => wrapper.findByTestId('resolve-conflicts-button'); + const findMergeLocalButton = () => wrapper.findByTestId('merge-locally-button'); - vm = shallowMount(localVue.extend(ConflictsComponent), { - propsData, - provide: { - glFeatures: { - mergeRequestWidgetGraphql, + function createComponent(propsData = {}) { + wrapper = extendedWrapper( + shallowMount(ConflictsComponent, { + propsData, + provide: { + glFeatures: { + mergeRequestWidgetGraphql, + }, }, - }, - mocks: { - $apollo: { - queries: { - userPermissions: { loading: false }, - stateData: { loading: false }, + mocks: { + $apollo: { + queries: { + userPermissions: { loading: false }, + stateData: { loading: false }, + }, }, }, - }, - }); + }), + ); if (mergeRequestWidgetGraphql) { - vm.setData({ + wrapper.setData({ userPermissions: { canMerge: propsData.mr.canMerge, pushToSourceBranch: propsData.mr.canPushToSourceBranch, @@ -42,16 +47,12 @@ describe('MRWidgetConflicts', () => { }); } - return vm.vm.$nextTick(); + return wrapper.vm.$nextTick(); } - beforeEach(() => { - jest.spyOn($.fn, 'popover'); - }); - afterEach(() => { mergeRequestWidgetGraphql = null; - vm.destroy(); + wrapper.destroy(); }); [false, true].forEach((featureEnabled) => { @@ -82,18 +83,16 @@ describe('MRWidgetConflicts', () => { }); it('should tell you about conflicts without bothering other people', () => { - expect(vm.text()).toContain('There are merge conflicts'); - expect(vm.text()).not.toContain('ask someone with write access'); + expect(wrapper.text()).toContain('There are merge conflicts'); + expect(wrapper.text()).not.toContain('ask someone with write access'); }); it('should not allow you to resolve the conflicts', () => { - expect(vm.text()).not.toContain('Resolve conflicts'); + expect(wrapper.text()).not.toContain('Resolve conflicts'); }); it('should have merge buttons', () => { - const mergeLocallyButton = vm.find('.js-merge-locally-button'); - - expect(mergeLocallyButton.text()).toContain('Merge locally'); + expect(findMergeLocalButton().text()).toContain('Merge locally'); }); }); @@ -110,19 +109,17 @@ describe('MRWidgetConflicts', () => { }); it('should tell you about conflicts', () => { - expect(vm.text()).toContain('There are merge conflicts'); - expect(vm.text()).toContain('ask someone with write access'); + expect(wrapper.text()).toContain('There are merge conflicts'); + expect(wrapper.text()).toContain('ask someone with write access'); }); it('should allow you to resolve the conflicts', () => { - const resolveButton = vm.find('.js-resolve-conflicts-button'); - - expect(resolveButton.text()).toContain('Resolve conflicts'); - expect(resolveButton.attributes('href')).toEqual(path); + expect(findResolveButton().text()).toContain('Resolve conflicts'); + expect(findResolveButton().attributes('href')).toEqual(path); }); it('should not have merge buttons', () => { - expect(vm.text()).not.toContain('Merge locally'); + expect(wrapper.text()).not.toContain('Merge locally'); }); }); @@ -139,21 +136,17 @@ describe('MRWidgetConflicts', () => { }); it('should tell you about conflicts without bothering other people', () => { - expect(vm.text()).toContain('There are merge conflicts'); - expect(vm.text()).not.toContain('ask someone with write access'); + expect(wrapper.text()).toContain('There are merge conflicts'); + expect(wrapper.text()).not.toContain('ask someone with write access'); }); it('should allow you to resolve the conflicts', () => { - const resolveButton = vm.find('.js-resolve-conflicts-button'); - - expect(resolveButton.text()).toContain('Resolve conflicts'); - expect(resolveButton.attributes('href')).toEqual(path); + expect(findResolveButton().text()).toContain('Resolve conflicts'); + expect(findResolveButton().attributes('href')).toEqual(path); }); it('should have merge buttons', () => { - const mergeLocallyButton = vm.find('.js-merge-locally-button'); - - expect(mergeLocallyButton.text()).toContain('Merge locally'); + expect(findMergeLocalButton().text()).toContain('Merge locally'); }); }); @@ -167,7 +160,7 @@ describe('MRWidgetConflicts', () => { }, }); - expect(vm.text().trim().replace(/\s\s+/g, ' ')).toContain( + expect(wrapper.text().trim().replace(/\s\s+/g, ' ')).toContain( 'ask someone with write access', ); }); @@ -181,8 +174,8 @@ describe('MRWidgetConflicts', () => { }, }); - expect(vm.find('.js-resolve-conflicts-button').exists()).toBe(false); - expect(vm.find('.js-merge-locally-button').exists()).toBe(false); + expect(findResolveButton().exists()).toBe(false); + expect(findMergeLocalButton().exists()).toBe(false); }); it('should not have resolve button when no conflict resolution path', async () => { @@ -194,7 +187,7 @@ describe('MRWidgetConflicts', () => { }, }); - expect(vm.find('.js-resolve-conflicts-button').exists()).toBe(false); + expect(findResolveButton().exists()).toBe(false); }); }); @@ -207,7 +200,7 @@ describe('MRWidgetConflicts', () => { }, }); - expect(removeBreakLine(vm.text()).trim()).toContain( + expect(removeBreakLine(wrapper.text()).trim()).toContain( 'Fast-forward merge is not possible. To merge this request, first rebase locally.', ); }); @@ -227,11 +220,11 @@ describe('MRWidgetConflicts', () => { }); it('sets resolve button as disabled', () => { - expect(vm.find('.js-resolve-conflicts-button').attributes('disabled')).toBe('true'); + expect(findResolveButton().attributes('disabled')).toBe('true'); }); - it('renders popover', () => { - expect($.fn.popover).toHaveBeenCalled(); + it('shows the popover', () => { + expect(findPopover().exists()).toBe(true); }); }); @@ -249,11 +242,11 @@ describe('MRWidgetConflicts', () => { }); it('sets resolve button as disabled', () => { - expect(vm.find('.js-resolve-conflicts-button').attributes('disabled')).toBe(undefined); + expect(findResolveButton().attributes('disabled')).toBe(undefined); }); - it('renders popover', () => { - expect($.fn.popover).not.toHaveBeenCalled(); + it('does not show the popover', () => { + expect(findPopover().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js index 222cb74cc66..b16fb5171e7 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js @@ -1,29 +1,30 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import mergingComponent from '~/vue_merge_request_widget/components/states/mr_widget_merging.vue'; +import { shallowMount } from '@vue/test-utils'; +import MrWidgetMerging from '~/vue_merge_request_widget/components/states/mr_widget_merging.vue'; describe('MRWidgetMerging', () => { - let vm; - beforeEach(() => { - const Component = Vue.extend(mergingComponent); + let wrapper; - vm = mountComponent(Component, { - mr: { - targetBranchPath: '/branch-path', - targetBranch: 'branch', + beforeEach(() => { + wrapper = shallowMount(MrWidgetMerging, { + propsData: { + mr: { + targetBranchPath: '/branch-path', + targetBranch: 'branch', + }, }, }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders information about merge request being merged', () => { expect( - vm.$el - .querySelector('.media-body') - .textContent.trim() + wrapper + .find('.media-body') + .text() + .trim() .replace(/\s\s+/g, ' ') .replace(/[\r\n]+/g, ' '), ).toContain('This merge request is in the process of being merged'); @@ -31,13 +32,14 @@ describe('MRWidgetMerging', () => { it('renders branch information', () => { expect( - vm.$el - .querySelector('.mr-info-list') - .textContent.trim() + wrapper + .find('.mr-info-list') + .text() + .trim() .replace(/\s\s+/g, ' ') .replace(/[\r\n]+/g, ' '), ).toEqual('The changes will be merged into branch'); - expect(vm.$el.querySelector('a').getAttribute('href')).toEqual('/branch-path'); + expect(wrapper.find('a').attributes('href')).toBe('/branch-path'); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js index bd0bd36ebc2..2c04905d3a9 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js @@ -14,20 +14,14 @@ describe('NothingToMerge', () => { it('should have correct elements', () => { expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); - expect(vm.$el.querySelector('a').href).toContain(newBlobPath); - expect(vm.$el.innerText).toContain( - "Currently there are no changes in this merge request's source branch", - ); - - expect(vm.$el.innerText.replace(/\s\s+/g, ' ')).toContain( - 'Please push new commits or use a different branch.', - ); + expect(vm.$el.querySelector('[data-testid="createFileButton"]').href).toContain(newBlobPath); + expect(vm.$el.innerText).toContain('Use merge requests to propose changes to your project'); }); it('should not show new blob link if there is no link available', () => { vm.mr.newBlobPath = null; Vue.nextTick(() => { - expect(vm.$el.querySelector('a')).toEqual(null); + expect(vm.$el.querySelector('[data-testid="createFileButton"]')).toEqual(null); }); }); }); diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js new file mode 100644 index 00000000000..dd0c483b28a --- /dev/null +++ b/spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js @@ -0,0 +1,101 @@ +import { mount } from '@vue/test-utils'; +import { zip } from 'lodash'; +import { trimText } from 'helpers/text_helper'; +import Deployment from '~/vue_merge_request_widget/components/deployment/deployment.vue'; +import DeploymentList from '~/vue_merge_request_widget/components/deployment/deployment_list.vue'; +import MrCollapsibleExtension from '~/vue_merge_request_widget/components/mr_collapsible_extension.vue'; +import { mockStore } from '../mock_data'; + +const DEFAULT_PROPS = { + showVisualReviewAppLink: false, + hasDeploymentMetrics: false, + deploymentClass: 'js-pre-deployment', +}; + +describe('~/vue_merge_request_widget/components/deployment/deployment_list.vue', () => { + let wrapper; + let propsData; + + const factory = (props = {}) => { + propsData = { + ...DEFAULT_PROPS, + deployments: mockStore.deployments, + ...props, + }; + wrapper = mount(DeploymentList, { + propsData, + }); + }; + + afterEach(() => { + wrapper?.destroy?.(); + wrapper = null; + }); + + describe('with few deployments', () => { + beforeEach(() => { + factory(); + }); + + it('shows all deployments', () => { + const deploymentWrappers = wrapper.findAllComponents(Deployment); + expect(wrapper.findComponent(MrCollapsibleExtension).exists()).toBe(false); + expect(deploymentWrappers).toHaveLength(propsData.deployments.length); + + zip(deploymentWrappers.wrappers, propsData.deployments).forEach( + ([deploymentWrapper, deployment]) => { + expect(deploymentWrapper.props('deployment')).toEqual(deployment); + expect(deploymentWrapper.props()).toMatchObject({ + showVisualReviewApp: DEFAULT_PROPS.showVisualReviewAppLink, + showMetrics: DEFAULT_PROPS.hasDeploymentMetrics, + }); + expect(deploymentWrapper.classes(DEFAULT_PROPS.deploymentClass)).toBe(true); + expect(deploymentWrapper.text()).toEqual(expect.any(String)); + expect(deploymentWrapper.text()).not.toBe(''); + }, + ); + }); + }); + describe('with many deployments', () => { + let deployments; + let collapsibleExtension; + + beforeEach(() => { + deployments = [ + ...mockStore.deployments, + ...mockStore.deployments.map((deployment) => ({ + ...deployment, + id: deployment.id + mockStore.deployments.length, + })), + ]; + factory({ deployments }); + + collapsibleExtension = wrapper.findComponent(MrCollapsibleExtension); + }); + + it('shows collapsed deployments', () => { + expect(collapsibleExtension.exists()).toBe(true); + expect(trimText(collapsibleExtension.text())).toBe( + `${deployments.length} environments impacted. View all environments.`, + ); + }); + it('shows all deployments on click', async () => { + await collapsibleExtension.find('button').trigger('click'); + const deploymentWrappers = wrapper.findAllComponents(Deployment); + expect(deploymentWrappers).toHaveLength(deployments.length); + + zip(deploymentWrappers.wrappers, propsData.deployments).forEach( + ([deploymentWrapper, deployment]) => { + expect(deploymentWrapper.props('deployment')).toEqual(deployment); + expect(deploymentWrapper.props()).toMatchObject({ + showVisualReviewApp: DEFAULT_PROPS.showVisualReviewAppLink, + showMetrics: DEFAULT_PROPS.hasDeploymentMetrics, + }); + expect(deploymentWrapper.classes(DEFAULT_PROPS.deploymentClass)).toBe(true); + expect(deploymentWrapper.text()).toEqual(expect.any(String)); + expect(deploymentWrapper.text()).not.toBe(''); + }, + ); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index 7b020813bd5..c4962b608e1 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -91,18 +91,6 @@ describe('MrWidgetOptions', () => { }); }); - describe('shouldRenderMergeHelp', () => { - it('should return false for the initial merged state', () => { - expect(wrapper.vm.shouldRenderMergeHelp).toBeFalsy(); - }); - - it('should return true for a state which requires help widget', () => { - wrapper.vm.mr.state = 'conflicts'; - - expect(wrapper.vm.shouldRenderMergeHelp).toBeTruthy(); - }); - }); - describe('shouldRenderPipelines', () => { it('should return true when hasCI is true', () => { wrapper.vm.mr.hasCI = true; diff --git a/spec/frontend/vue_shared/alert_details/alert_details_spec.js b/spec/frontend/vue_shared/alert_details/alert_details_spec.js index ce410a8b3e7..68bcf1dc491 100644 --- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js @@ -89,7 +89,7 @@ describe('AlertDetails', () => { const findIncidentCreationAlert = () => wrapper.findByTestId('incidentCreationError'); const findEnvironmentName = () => wrapper.findByTestId('environmentName'); const findEnvironmentPath = () => wrapper.findByTestId('environmentPath'); - const findDetailsTable = () => wrapper.find(AlertDetailsTable); + const findDetailsTable = () => wrapper.findComponent(AlertDetailsTable); const findMetricsTab = () => wrapper.findByTestId('metrics'); describe('Alert details', () => { @@ -188,27 +188,39 @@ describe('AlertDetails', () => { }); expect(findMetricsTab().exists()).toBe(false); }); + + it('should display "View incident" button that links the issues page when incident exists', () => { + const iid = '3'; + mountComponent({ + data: { alert: { ...mockAlert, issue: { iid } }, sidebarStatus: false }, + provide: { isThreatMonitoringPage: true }, + }); + + expect(findViewIncidentBtn().exists()).toBe(true); + expect(findViewIncidentBtn().attributes('href')).toBe(joinPaths(projectIssuesPath, iid)); + expect(findCreateIncidentBtn().exists()).toBe(false); + }); }); describe('Create incident from alert', () => { it('should display "View incident" button that links the incident page when incident exists', () => { - const issueIid = '3'; + const iid = '3'; mountComponent({ - data: { alert: { ...mockAlert, issueIid }, sidebarStatus: false }, + data: { alert: { ...mockAlert, issue: { iid } }, sidebarStatus: false }, }); expect(findViewIncidentBtn().exists()).toBe(true); expect(findViewIncidentBtn().attributes('href')).toBe( - joinPaths(projectIssuesPath, issueIid), + joinPaths(projectIssuesPath, 'incident', iid), ); expect(findCreateIncidentBtn().exists()).toBe(false); }); it('should display "Create incident" button when incident doesn\'t exist yet', () => { - const issueIid = null; + const issue = null; mountComponent({ mountMethod: mount, - data: { alert: { ...mockAlert, issueIid } }, + data: { alert: { ...mockAlert, issue } }, }); return wrapper.vm.$nextTick().then(() => { diff --git a/spec/frontend/vue_shared/alert_details/mocks/alerts.json b/spec/frontend/vue_shared/alert_details/mocks/alerts.json index 5267a4fe50d..007557e234a 100644 --- a/spec/frontend/vue_shared/alert_details/mocks/alerts.json +++ b/spec/frontend/vue_shared/alert_details/mocks/alerts.json @@ -21,7 +21,7 @@ "endedAt": "2020-04-17T23:18:14.996Z", "status": "ACKNOWLEDGED", "assignees": { "nodes": [{ "username": "root", "avatarUrl": "/url", "name": "root" }] }, - "issueIid": "1", + "issue": { "state" : "closed", "iid": "1", "title": "My test issue" }, "notes": { "nodes": [ { diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js index f34a2db0851..99bf0d84d0c 100644 --- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js +++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js @@ -88,7 +88,7 @@ describe('RelatedIssuableItem', () => { const stateTitle = tokenState().attributes('title'); const formattedCreateDate = formatDate(props.createdAt); - expect(stateTitle).toContain('<span class="bold">Opened</span>'); + expect(stateTitle).toContain('<span class="bold">Created</span>'); expect(stateTitle).toContain(`<span class="text-tertiary">${formattedCreateDate}</span>`); }); diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js index bf65adc866d..5364e2d5f52 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js @@ -1,5 +1,6 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import ApplySuggestion from '~/vue_shared/components/markdown/apply_suggestion.vue'; import SuggestionDiffHeader from '~/vue_shared/components/markdown/suggestion_diff_header.vue'; const DEFAULT_PROPS = { @@ -38,7 +39,7 @@ describe('Suggestion Diff component', () => { wrapper.destroy(); }); - const findApplyButton = () => wrapper.find('.js-apply-btn'); + const findApplyButton = () => wrapper.find(ApplySuggestion); const findApplyBatchButton = () => wrapper.find('.js-apply-batch-btn'); const findAddToBatchButton = () => wrapper.find('.js-add-to-batch-btn'); const findRemoveFromBatchButton = () => wrapper.find('.js-remove-from-batch-btn'); @@ -88,7 +89,7 @@ describe('Suggestion Diff component', () => { beforeEach(() => { createComponent(); - findApplyButton().vm.$emit('click'); + findApplyButton().vm.$emit('apply'); }); it('emits apply', () => { diff --git a/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js b/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js index 99671f1ffb7..566ca1817f2 100644 --- a/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js @@ -1,3 +1,4 @@ +import { GlDropdown } from '@gitlab/ui'; import { getByText } from '@testing-library/dom'; import { shallowMount } from '@vue/test-utils'; import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; @@ -25,6 +26,9 @@ describe('MultiSelectDropdown Component', () => { slots: { search: '<p>Search</p>', }, + stubs: { + GlDropdown, + }, }); expect(getByText(wrapper.element, 'Search')).toBeDefined(); }); diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap index da49778f216..30b7f0c2d28 100644 --- a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap +++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap @@ -2,20 +2,26 @@ exports[`Package code instruction multiline to match the snapshot 1`] = ` <div> - <pre - class="gl-font-monospace" - data-testid="multiline-instruction" + <label + for="instruction-input_3" > - this is some + foo_label + </label> + + <div> + <pre + class="gl-font-monospace" + data-testid="multiline-instruction" + > + this is some multiline text - </pre> + </pre> + </div> </div> `; exports[`Package code instruction single line to match the default snapshot 1`] = ` -<div - class="gl-mb-3" -> +<div> <label for="instruction-input_2" > @@ -23,42 +29,46 @@ exports[`Package code instruction single line to match the default snapshot 1`] </label> <div - class="input-group gl-mb-3" + class="gl-mb-3" > - <input - class="form-control gl-font-monospace" - data-testid="instruction-input" - id="instruction-input_2" - readonly="readonly" - type="text" - /> - - <span - class="input-group-append" - data-testid="instruction-button" + <div + class="input-group gl-mb-3" > - <button - aria-label="Copy this value" - class="btn input-group-text btn-default btn-md gl-button btn-default-secondary btn-icon" - data-clipboard-text="npm i @my-package" - title="Copy npm install command" - type="button" + <input + class="form-control gl-font-monospace" + data-testid="instruction-input" + id="instruction-input_2" + readonly="readonly" + type="text" + /> + + <span + class="input-group-append" + data-testid="instruction-button" > - <!----> - - <svg - aria-hidden="true" - class="gl-button-icon gl-icon s16" - data-testid="copy-to-clipboard-icon" + <button + aria-label="Copy this value" + class="btn input-group-text btn-default btn-md gl-button btn-default-secondary btn-icon" + data-clipboard-text="npm i @my-package" + title="Copy npm install command" + type="button" > - <use - href="#copy-to-clipboard" - /> - </svg> - - <!----> - </button> - </span> + <!----> + + <svg + aria-hidden="true" + class="gl-button-icon gl-icon s16" + data-testid="copy-to-clipboard-icon" + > + <use + href="#copy-to-clipboard" + /> + </svg> + + <!----> + </button> + </span> + </div> </div> </div> `; diff --git a/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js b/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js new file mode 100644 index 00000000000..c65ded000d3 --- /dev/null +++ b/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js @@ -0,0 +1,122 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import component from '~/vue_shared/components/registry/persisted_dropdown_selection.vue'; + +describe('Persisted dropdown selection', () => { + let wrapper; + + const defaultProps = { + storageKey: 'foo_bar', + options: [ + { value: 'maven', label: 'Maven' }, + { value: 'gradle', label: 'Gradle' }, + ], + }; + + function createComponent({ props = {}, data = {} } = {}) { + wrapper = shallowMount(component, { + propsData: { + ...defaultProps, + ...props, + }, + data() { + return data; + }, + }); + } + + const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('local storage sync', () => { + it('uses the local storage sync component', () => { + createComponent(); + + expect(findLocalStorageSync().exists()).toBe(true); + }); + + it('passes the right props', () => { + createComponent({ data: { selected: 'foo' } }); + + expect(findLocalStorageSync().props()).toMatchObject({ + storageKey: defaultProps.storageKey, + value: 'foo', + }); + }); + + it('on input event updates the model and emits event', async () => { + const inputPayload = 'bar'; + createComponent(); + findLocalStorageSync().vm.$emit('input', inputPayload); + + await nextTick(); + + expect(wrapper.emitted('change')).toStrictEqual([[inputPayload]]); + expect(findLocalStorageSync().props('value')).toBe(inputPayload); + }); + }); + + describe('dropdown', () => { + it('has a dropdown component', () => { + createComponent(); + + expect(findDropdown().exists()).toBe(true); + }); + + describe('dropdown text', () => { + it('when no selection shows the first', () => { + createComponent(); + + expect(findDropdown().props('text')).toBe('Maven'); + }); + + it('when an option is selected, shows that option label', () => { + createComponent({ data: { selected: defaultProps.options[1].value } }); + + expect(findDropdown().props('text')).toBe('Gradle'); + }); + }); + + describe('dropdown items', () => { + it('has one item for each option', () => { + createComponent(); + + expect(findDropdownItems()).toHaveLength(defaultProps.options.length); + }); + + it('binds the correct props', () => { + createComponent({ data: { selected: defaultProps.options[0].value } }); + + expect(findDropdownItems().at(0).props()).toMatchObject({ + isChecked: true, + isCheckItem: true, + }); + + expect(findDropdownItems().at(1).props()).toMatchObject({ + isChecked: false, + isCheckItem: true, + }); + }); + + it('on click updates the data and emits event', async () => { + createComponent({ data: { selected: defaultProps.options[0].value } }); + expect(findDropdownItems().at(0).props('isChecked')).toBe(true); + + findDropdownItems().at(1).vm.$emit('click'); + + await nextTick(); + + expect(wrapper.emitted('change')).toStrictEqual([['gradle']]); + expect(findDropdownItems().at(0).props('isChecked')).toBe(false); + expect(findDropdownItems().at(1).props('isChecked')).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap b/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap index 51b8aa162bc..ed085fb66dc 100644 --- a/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap +++ b/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap @@ -2,7 +2,7 @@ exports[`Settings Block renders the correct markup 1`] = ` <section - class="settings no-animate" + class="settings" > <div class="settings-header" diff --git a/spec/frontend/vue_shared/components/settings/settings_block_spec.js b/spec/frontend/vue_shared/components/settings/settings_block_spec.js index 2db0b001b5b..be5a15631eb 100644 --- a/spec/frontend/vue_shared/components/settings/settings_block_spec.js +++ b/spec/frontend/vue_shared/components/settings/settings_block_spec.js @@ -50,6 +50,27 @@ describe('Settings Block', () => { expect(findDescriptionSlot().exists()).toBe(true); }); + describe('slide animation behaviour', () => { + it('is animated by default', () => { + mountComponent(); + + expect(wrapper.classes('no-animate')).toBe(false); + }); + + it.each` + slideAnimated | noAnimatedClass + ${true} | ${false} + ${false} | ${true} + `( + 'sets the correct state when slideAnimated is $slideAnimated', + ({ slideAnimated, noAnimatedClass }) => { + mountComponent({ slideAnimated }); + + expect(wrapper.classes('no-animate')).toBe(noAnimatedClass); + }, + ); + }); + describe('expanded behaviour', () => { it('is collapsed by default', () => { mountComponent(); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js index 0d1d6ebcfe5..c90e63313b2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js @@ -11,32 +11,31 @@ import { mockConfig, mockRegularLabel, mockScopedLabel } from './mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); -const createComponent = (initialState = mockConfig, slots = {}) => { - const store = new Vuex.Store(labelsSelectModule()); - - store.dispatch('setInitialState', initialState); - - return shallowMount(DropdownValue, { - localVue, - store, - slots, - }); -}; - describe('DropdownValue', () => { let wrapper; - beforeEach(() => { - wrapper = createComponent(); - }); + const createComponent = (initialState = {}, slots = {}) => { + const store = new Vuex.Store(labelsSelectModule()); + + store.dispatch('setInitialState', { ...mockConfig, ...initialState }); + + wrapper = shallowMount(DropdownValue, { + localVue, + store, + slots, + }); + }; afterEach(() => { wrapper.destroy(); + wrapper = null; }); describe('methods', () => { describe('labelFilterUrl', () => { it('returns a label filter URL based on provided label param', () => { + createComponent(); + expect(wrapper.vm.labelFilterUrl(mockRegularLabel)).toBe( '/gitlab-org/my-project/issues?label_name[]=Foo%20Label', ); @@ -44,6 +43,10 @@ describe('DropdownValue', () => { }); describe('scopedLabel', () => { + beforeEach(() => { + createComponent(); + }); + it('returns `true` when provided label param is a scoped label', () => { expect(wrapper.vm.scopedLabel(mockScopedLabel)).toBe(true); }); @@ -56,28 +59,29 @@ describe('DropdownValue', () => { describe('template', () => { it('renders class `has-labels` on component container element when `selectedLabels` is not empty', () => { + createComponent(); + expect(wrapper.attributes('class')).toContain('has-labels'); }); it('renders element containing `None` when `selectedLabels` is empty', () => { - const wrapperNoLabels = createComponent( + createComponent( { - ...mockConfig, selectedLabels: [], }, { default: 'None', }, ); - const noneEl = wrapperNoLabels.find('span.text-secondary'); + const noneEl = wrapper.find('span.text-secondary'); expect(noneEl.exists()).toBe(true); expect(noneEl.text()).toBe('None'); - - wrapperNoLabels.destroy(); }); it('renders labels when `selectedLabels` is not empty', () => { + createComponent(); + expect(wrapper.findAll(GlLabel).length).toBe(2); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js index 85a14226585..f293b8422e7 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js @@ -47,6 +47,7 @@ export const mockConfig = { labelsFetchPath: '/gitlab-org/my-project/-/labels.json', labelsManagePath: '/gitlab-org/my-project/-/labels', labelsFilterBasePath: '/gitlab-org/my-project/issues', + labelsFilterParam: 'label_name', }; export const mockSuggestedColors = { diff --git a/spec/frontend/vue_shared/components/tabs/tab_spec.js b/spec/frontend/vue_shared/components/tabs/tab_spec.js deleted file mode 100644 index ee0c983c764..00000000000 --- a/spec/frontend/vue_shared/components/tabs/tab_spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import Tab from '~/vue_shared/components/tabs/tab.vue'; - -describe('Tab component', () => { - const Component = Vue.extend(Tab); - let vm; - - beforeEach(() => { - vm = mountComponent(Component); - }); - - it('sets localActive to equal active', (done) => { - vm.active = true; - - vm.$nextTick(() => { - expect(vm.localActive).toBe(true); - - done(); - }); - }); - - it('sets active class', (done) => { - vm.active = true; - - vm.$nextTick(() => { - expect(vm.$el.classList).toContain('active'); - - done(); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/tabs/tabs_spec.js b/spec/frontend/vue_shared/components/tabs/tabs_spec.js deleted file mode 100644 index fe7be5be899..00000000000 --- a/spec/frontend/vue_shared/components/tabs/tabs_spec.js +++ /dev/null @@ -1,61 +0,0 @@ -import Vue from 'vue'; -import Tab from '~/vue_shared/components/tabs/tab.vue'; -import Tabs from '~/vue_shared/components/tabs/tabs'; - -describe('Tabs component', () => { - let vm; - - beforeEach(() => { - vm = new Vue({ - components: { - Tabs, - Tab, - }, - render(h) { - return h('div', [ - h('tabs', [ - h('tab', { attrs: { title: 'Testing', active: true } }, 'First tab'), - h('tab', [h('template', { slot: 'title' }, 'Test slot'), 'Second tab']), - ]), - ]); - }, - }).$mount(); - - return vm.$nextTick(); - }); - - describe('tab links', () => { - it('renders links for tabs', () => { - expect(vm.$el.querySelectorAll('a').length).toBe(2); - }); - - it('renders link titles from props', () => { - expect(vm.$el.querySelector('a').textContent).toContain('Testing'); - }); - - it('renders link titles from slot', () => { - expect(vm.$el.querySelectorAll('a')[1].textContent).toContain('Test slot'); - }); - - it('renders active class', () => { - expect(vm.$el.querySelector('a').classList).toContain('active'); - }); - - it('updates active class on click', () => { - vm.$el.querySelectorAll('a')[1].click(); - - return vm.$nextTick(() => { - expect(vm.$el.querySelector('a').classList).not.toContain('active'); - expect(vm.$el.querySelectorAll('a')[1].classList).toContain('active'); - }); - }); - }); - - describe('content', () => { - it('renders content panes', () => { - expect(vm.$el.querySelectorAll('.tab-pane').length).toBe(2); - expect(vm.$el.querySelectorAll('.tab-pane')[0].textContent).toContain('First tab'); - expect(vm.$el.querySelectorAll('.tab-pane')[1].textContent).toContain('Second tab'); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js index 27c9b099306..380b7231acd 100644 --- a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js +++ b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js @@ -11,6 +11,15 @@ jest.mock('~/lib/utils/dom_utils', () => ({ throw new Error('this needs to be mocked'); }), })); +jest.mock('@gitlab/ui', () => ({ + GlTooltipDirective: { + bind(el, binding) { + el.classList.add('gl-tooltip'); + el.setAttribute('data-original-title', el.title); + el.dataset.placement = binding.value.placement; + }, + }, +})); describe('TooltipOnTruncate component', () => { let wrapper; @@ -52,7 +61,7 @@ describe('TooltipOnTruncate component', () => { wrapper = parent.find(TooltipOnTruncate); }; - const hasTooltip = () => wrapper.classes('js-show-tooltip'); + const hasTooltip = () => wrapper.classes('gl-tooltip'); afterEach(() => { wrapper.destroy(); diff --git a/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap index d2fe3cd76cb..af4fa462cbf 100644 --- a/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap +++ b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap @@ -19,6 +19,7 @@ exports[`Upload dropzone component correctly overrides description and drop mess <p class="gl-mb-0" + data-testid="upload-text" > <span> Test %{linkStart}description%{linkEnd} message. @@ -98,10 +99,15 @@ exports[`Upload dropzone component when dragging renders correct template when d <p class="gl-mb-0" + data-testid="upload-text" > - <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} files to attach" - /> + Drop or + <gl-link-stub> + + upload + + </gl-link-stub> + files to attach </p> </div> </button> @@ -178,10 +184,15 @@ exports[`Upload dropzone component when dragging renders correct template when d <p class="gl-mb-0" + data-testid="upload-text" > - <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} files to attach" - /> + Drop or + <gl-link-stub> + + upload + + </gl-link-stub> + files to attach </p> </div> </button> @@ -258,10 +269,15 @@ exports[`Upload dropzone component when dragging renders correct template when d <p class="gl-mb-0" + data-testid="upload-text" > - <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} files to attach" - /> + Drop or + <gl-link-stub> + + upload + + </gl-link-stub> + files to attach </p> </div> </button> @@ -337,10 +353,15 @@ exports[`Upload dropzone component when dragging renders correct template when d <p class="gl-mb-0" + data-testid="upload-text" > - <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} files to attach" - /> + Drop or + <gl-link-stub> + + upload + + </gl-link-stub> + files to attach </p> </div> </button> @@ -416,10 +437,15 @@ exports[`Upload dropzone component when dragging renders correct template when d <p class="gl-mb-0" + data-testid="upload-text" > - <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} files to attach" - /> + Drop or + <gl-link-stub> + + upload + + </gl-link-stub> + files to attach </p> </div> </button> @@ -495,10 +521,15 @@ exports[`Upload dropzone component when no slot provided renders default dropzon <p class="gl-mb-0" + data-testid="upload-text" > - <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} files to attach" - /> + Drop or + <gl-link-stub> + + upload + + </gl-link-stub> + files to attach </p> </div> </button> diff --git a/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js index ace486b1f32..b3cdbccb271 100644 --- a/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js +++ b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js @@ -1,4 +1,4 @@ -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; @@ -14,6 +14,7 @@ describe('Upload dropzone component', () => { const findDropzoneCard = () => wrapper.find('.upload-dropzone-card'); const findDropzoneArea = () => wrapper.find('[data-testid="dropzone-area"]'); const findIcon = () => wrapper.find(GlIcon); + const findUploadText = () => wrapper.find('[data-testid="upload-text"]').text(); function createComponent({ slots = {}, data = {}, props = {} } = {}) { wrapper = shallowMount(UploadDropzone, { @@ -22,6 +23,9 @@ describe('Upload dropzone component', () => { displayAsCard: true, ...props, }, + stubs: { + GlSprintf, + }, data() { return data; }, @@ -30,6 +34,7 @@ describe('Upload dropzone component', () => { afterEach(() => { wrapper.destroy(); + wrapper = null; }); describe('when slot provided', () => { @@ -60,6 +65,18 @@ describe('Upload dropzone component', () => { }); }); + describe('upload text', () => { + it.each` + collection | description | props | expected + ${'multiple'} | ${'by default'} | ${null} | ${'files to attach'} + ${'singular'} | ${'when singleFileSelection'} | ${{ singleFileSelection: true }} | ${'file to attach'} + `('displays $collection version $description', ({ props, expected }) => { + createComponent({ props }); + + expect(findUploadText()).toContain(expected); + }); + }); + describe('when dragging', () => { it.each` description | eventPayload @@ -141,6 +158,21 @@ describe('Upload dropzone component', () => { wrapper.vm.ondrop(mockEvent); expect(wrapper.emitted()).not.toHaveProperty('error'); }); + + describe('singleFileSelection = true', () => { + it('emits a single file on drop', () => { + createComponent({ + data: mockData, + props: { singleFileSelection: true }, + }); + + const mockFile = { type: 'image/jpg' }; + const mockEvent = mockDragEvent({ files: [mockFile] }); + + wrapper.vm.ondrop(mockEvent); + expect(wrapper.emitted().change[0]).toEqual([mockFile]); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/user_access_role_badge_spec.js b/spec/frontend/vue_shared/components/user_access_role_badge_spec.js new file mode 100644 index 00000000000..7f25f7c08e7 --- /dev/null +++ b/spec/frontend/vue_shared/components/user_access_role_badge_spec.js @@ -0,0 +1,26 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; + +describe('UserAccessRoleBadge', () => { + let wrapper; + + const createComponent = ({ slots } = {}) => { + wrapper = shallowMount(UserAccessRoleBadge, { + slots, + }); + }; + + it('renders slot content inside GlBadge', () => { + createComponent({ + slots: { + default: 'test slot content', + }, + }); + + const badge = wrapper.find(GlBadge); + + expect(badge.exists()).toBe(true); + expect(badge.html()).toContain('test slot content'); + }); +}); diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index a6c5e23ae14..184a1e458b5 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -9,6 +9,7 @@ const DEFAULT_PROPS = { username: 'root', name: 'Administrator', location: 'Vienna', + bot: false, bio: null, workInformation: null, status: null, @@ -18,14 +19,10 @@ const DEFAULT_PROPS = { describe('User Popover Component', () => { const fixtureTemplate = 'merge_requests/diff_comment.html'; - preloadFixtures(fixtureTemplate); let wrapper; beforeEach(() => { - window.gon.features = { - securityAutoFix: true, - }; loadFixtures(fixtureTemplate); }); @@ -37,6 +34,7 @@ describe('User Popover Component', () => { const findUserStatus = () => wrapper.find('.js-user-status'); const findTarget = () => document.querySelector('.js-user-link'); const findUserName = () => wrapper.find(UserNameWithStatus); + const findSecurityBotDocsLink = () => findByTestId('user-popover-bot-docs-link'); const createWrapper = (props = {}, options = {}) => { wrapper = shallowMount(UserPopover, { @@ -86,6 +84,12 @@ describe('User Popover Component', () => { expect(iconEl.props('name')).toEqual('location'); }); + + it("should not show a link to bot's documentation", () => { + createWrapper(); + const securityBotDocsLink = findSecurityBotDocsLink(); + expect(securityBotDocsLink.exists()).toBe(false); + }); }); describe('job data', () => { @@ -230,14 +234,14 @@ describe('User Popover Component', () => { }); }); - describe('security bot', () => { + describe('bot user', () => { const SECURITY_BOT_USER = { ...DEFAULT_PROPS.user, name: 'GitLab Security Bot', username: 'GitLab-Security-Bot', websiteUrl: '/security/bot/docs', + bot: true, }; - const findSecurityBotDocsLink = () => findByTestId('user-popover-bot-docs-link'); it("shows a link to the bot's documentation", () => { createWrapper({ user: SECURITY_BOT_USER }); @@ -245,14 +249,5 @@ describe('User Popover Component', () => { expect(securityBotDocsLink.exists()).toBe(true); expect(securityBotDocsLink.attributes('href')).toBe(SECURITY_BOT_USER.websiteUrl); }); - - it('does not show the link if the feature flag is disabled', () => { - window.gon.features = { - securityAutoFix: false, - }; - createWrapper({ user: SECURITY_BOT_USER }); - - expect(findSecurityBotDocsLink().exists()).toBe(false); - }); }); }); diff --git a/spec/frontend/vue_shared/directives/tooltip_spec.js b/spec/frontend/vue_shared/directives/tooltip_spec.js deleted file mode 100644 index 99e8b5b552b..00000000000 --- a/spec/frontend/vue_shared/directives/tooltip_spec.js +++ /dev/null @@ -1,157 +0,0 @@ -import { mount } from '@vue/test-utils'; -import $ from 'jquery'; -import { escape } from 'lodash'; -import tooltip from '~/vue_shared/directives/tooltip'; - -const DEFAULT_TOOLTIP_TEMPLATE = '<div v-tooltip :title="tooltip"></div>'; -const HTML_TOOLTIP_TEMPLATE = '<div v-tooltip data-html="true" :title="tooltip"></div>'; - -describe('Tooltip directive', () => { - let wrapper; - - function createTooltipContainer({ - template = DEFAULT_TOOLTIP_TEMPLATE, - text = 'some text', - } = {}) { - wrapper = mount( - { - directives: { tooltip }, - data: () => ({ tooltip: text }), - template, - }, - { attachTo: document.body }, - ); - } - - async function showTooltip() { - $(wrapper.vm.$el).tooltip('show'); - jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); - } - - function findTooltipInnerHtml() { - return document.querySelector('.tooltip-inner').innerHTML; - } - - function findTooltipHtml() { - return document.querySelector('.tooltip').innerHTML; - } - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('with a single tooltip', () => { - it('should have tooltip plugin applied', () => { - createTooltipContainer(); - - expect($(wrapper.vm.$el).data('bs.tooltip')).toBeDefined(); - }); - - it('displays the title as tooltip', () => { - createTooltipContainer(); - - $(wrapper.vm.$el).tooltip('show'); - - jest.runOnlyPendingTimers(); - - const tooltipElement = document.querySelector('.tooltip-inner'); - - expect(tooltipElement.textContent).toContain('some text'); - }); - - it.each` - condition | template | sanitize - ${'does not contain any html'} | ${DEFAULT_TOOLTIP_TEMPLATE} | ${false} - ${'contains html'} | ${HTML_TOOLTIP_TEMPLATE} | ${true} - `('passes sanitize=$sanitize if the tooltip $condition', ({ template, sanitize }) => { - createTooltipContainer({ template }); - - expect($(wrapper.vm.$el).data('bs.tooltip').config.sanitize).toEqual(sanitize); - }); - - it('updates a visible tooltip', async () => { - createTooltipContainer(); - - $(wrapper.vm.$el).tooltip('show'); - jest.runOnlyPendingTimers(); - - const tooltipElement = document.querySelector('.tooltip-inner'); - - wrapper.vm.tooltip = 'other text'; - - jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); - - expect(tooltipElement.textContent).toContain('other text'); - }); - - describe('tooltip sanitization', () => { - it('reads tooltip content as text if data-html is not passed', async () => { - createTooltipContainer({ text: 'sample text<script>alert("XSS!!")</script>' }); - - await showTooltip(); - - const result = findTooltipInnerHtml(); - expect(result).toEqual('sample text<script>alert("XSS!!")</script>'); - }); - - it('sanitizes tooltip if data-html is passed', async () => { - createTooltipContainer({ - template: HTML_TOOLTIP_TEMPLATE, - text: 'sample text<script>alert("XSS!!")</script>', - }); - - await showTooltip(); - - const result = findTooltipInnerHtml(); - expect(result).toEqual('sample text'); - expect(result).not.toContain('XSS!!'); - }); - - it('sanitizes tooltip if data-template is passed', async () => { - const tooltipTemplate = escape( - '<div class="tooltip" role="tooltip"><div onclick="alert(\'XSS!\')" class="arrow"></div><div class="tooltip-inner"></div></div>', - ); - - createTooltipContainer({ - template: `<div v-tooltip :title="tooltip" data-html="false" data-template="${tooltipTemplate}"></div>`, - }); - - await showTooltip(); - - const result = findTooltipHtml(); - expect(result).toEqual( - // objectionable element is removed - '<div class="arrow"></div><div class="tooltip-inner">some text</div>', - ); - expect(result).not.toContain('XSS!!'); - }); - }); - }); - - describe('with multiple tooltips', () => { - beforeEach(() => { - createTooltipContainer({ - template: ` - <div> - <div - v-tooltip - class="js-look-for-tooltip" - title="foo"> - </div> - <div - v-tooltip - title="bar"> - </div> - </div> - `, - }); - }); - - it('should have tooltip plugin applied to all instances', () => { - expect($(wrapper.vm.$el).find('.js-look-for-tooltip').data('bs.tooltip')).toBeDefined(); - }); - }); -}); diff --git a/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js b/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js index 6ecc330b5af..3fb60c254c9 100644 --- a/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js +++ b/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js @@ -11,6 +11,10 @@ describe('GitLab Feature Flags Plugin', () => { aFeature: true, bFeature: false, }, + licensed_features: { + cFeature: true, + dFeature: false, + }, }; localVue.use(GlFeatureFlags); @@ -25,6 +29,8 @@ describe('GitLab Feature Flags Plugin', () => { expect(wrapper.vm.glFeatures).toEqual({ aFeature: true, bFeature: false, + cFeature: true, + dFeature: false, }); }); @@ -37,6 +43,8 @@ describe('GitLab Feature Flags Plugin', () => { expect(wrapper.vm.glFeatures).toEqual({ aFeature: true, bFeature: false, + cFeature: true, + dFeature: false, }); }); }); diff --git a/spec/frontend/zen_mode_spec.js b/spec/frontend/zen_mode_spec.js index 5cc1d2200d3..bf4b57d8afb 100644 --- a/spec/frontend/zen_mode_spec.js +++ b/spec/frontend/zen_mode_spec.js @@ -13,8 +13,6 @@ describe('ZenMode', () => { let dropzoneForElementSpy; const fixtureName = 'snippets/show.html'; - preloadFixtures(fixtureName); - function enterZen() { $('.notes-form .js-zen-enter').click(); } diff --git a/spec/frontend_integration/ide/helpers/ide_helper.js b/spec/frontend_integration/ide/helpers/ide_helper.js index 9e6bafc1297..6c09b44d891 100644 --- a/spec/frontend_integration/ide/helpers/ide_helper.js +++ b/spec/frontend_integration/ide/helpers/ide_helper.js @@ -25,6 +25,9 @@ export const getStatusBar = () => document.querySelector('.ide-status-bar'); export const waitForMonacoEditor = () => new Promise((resolve) => window.monaco.editor.onDidCreateEditor(resolve)); +export const waitForEditorModelChange = (instance) => + new Promise((resolve) => instance.onDidChangeModel(resolve)); + export const findMonacoEditor = () => screen.findAllByLabelText(/Editor content;/).then(([x]) => x.closest('.monaco-editor')); diff --git a/spec/frontend_integration/ide/helpers/mock_data.js b/spec/frontend_integration/ide/helpers/mock_data.js index f70739e5ac0..8c9ec74541f 100644 --- a/spec/frontend_integration/ide/helpers/mock_data.js +++ b/spec/frontend_integration/ide/helpers/mock_data.js @@ -4,7 +4,6 @@ export const IDE_DATASET = { committedStateSvgPath: '/test/committed_state.svg', pipelinesEmptyStateSvgPath: '/test/pipelines_empty_state.svg', promotionSvgPath: '/test/promotion.svg', - ciHelpPagePath: '/test/ci_help_page', webIDEHelpPagePath: '/test/web_ide_help_page', clientsidePreviewEnabled: 'true', renderWhitespaceInCode: 'false', diff --git a/spec/frontend_integration/ide/helpers/start.js b/spec/frontend_integration/ide/helpers/start.js index 173a9610c84..cc6abd9e01f 100644 --- a/spec/frontend_integration/ide/helpers/start.js +++ b/spec/frontend_integration/ide/helpers/start.js @@ -1,6 +1,7 @@ +/* global monaco */ + import { TEST_HOST } from 'helpers/test_constants'; import { initIde } from '~/ide'; -import Editor from '~/ide/lib/editor'; import extendStore from '~/ide/stores/extend'; import { IDE_DATASET } from './mock_data'; @@ -18,13 +19,7 @@ export default (container, { isRepoEmpty = false, path = '', mrId = '' } = {}) = const vm = initIde(el, { extendStore }); // We need to dispose of editor Singleton things or tests will bump into eachother - vm.$on('destroy', () => { - if (Editor.editorInstance) { - Editor.editorInstance.modelManager.dispose(); - Editor.editorInstance.dispose(); - Editor.editorInstance = null; - } - }); + vm.$on('destroy', () => monaco.editor.getModels().forEach((model) => model.dispose())); return vm; }; diff --git a/spec/frontend_integration/ide/ide_integration_spec.js b/spec/frontend_integration/ide/ide_integration_spec.js index 3ce88de11fe..5f1a5b0d048 100644 --- a/spec/frontend_integration/ide/ide_integration_spec.js +++ b/spec/frontend_integration/ide/ide_integration_spec.js @@ -96,16 +96,6 @@ describe('WebIDE', () => { let statusBar; let editor; - const waitForEditor = async () => { - editor = await ideHelper.waitForMonacoEditor(); - }; - - const changeEditorPosition = async (lineNumber, column) => { - editor.setPosition({ lineNumber, column }); - - await vm.$nextTick(); - }; - beforeEach(async () => { vm = startWebIDE(container); @@ -134,16 +124,17 @@ describe('WebIDE', () => { // Need to wait for monaco editor to load so it doesn't through errors on dispose await ideHelper.openFile('.gitignore'); - await ideHelper.waitForMonacoEditor(); + await ideHelper.waitForEditorModelChange(editor); await ideHelper.openFile('README.md'); - await ideHelper.waitForMonacoEditor(); + await ideHelper.waitForEditorModelChange(editor); expect(el).toHaveText(markdownPreview); }); describe('when editor position changes', () => { beforeEach(async () => { - await changeEditorPosition(4, 10); + editor.setPosition({ lineNumber: 4, column: 10 }); + await vm.$nextTick(); }); it('shows new line position', () => { @@ -153,7 +144,8 @@ describe('WebIDE', () => { it('updates after rename', async () => { await ideHelper.renameFile('README.md', 'READMEZ.txt'); - await waitForEditor(); + await ideHelper.waitForEditorModelChange(editor); + await vm.$nextTick(); expect(statusBar).toHaveText('1:1'); expect(statusBar).toHaveText('plaintext'); @@ -161,10 +153,10 @@ describe('WebIDE', () => { it('persists position after opening then rename', async () => { await ideHelper.openFile('files/js/application.js'); - await waitForEditor(); + await ideHelper.waitForEditorModelChange(editor); await ideHelper.renameFile('README.md', 'READING_RAINBOW.md'); await ideHelper.openFile('READING_RAINBOW.md'); - await waitForEditor(); + await ideHelper.waitForEditorModelChange(editor); expect(statusBar).toHaveText('4:10'); expect(statusBar).toHaveText('markdown'); @@ -173,7 +165,8 @@ describe('WebIDE', () => { it('persists position after closing', async () => { await ideHelper.closeFile('README.md'); await ideHelper.openFile('README.md'); - await waitForEditor(); + await ideHelper.waitForMonacoEditor(); + await vm.$nextTick(); expect(statusBar).toHaveText('4:10'); expect(statusBar).toHaveText('markdown'); diff --git a/spec/frontend_integration/ide/user_opens_mr_spec.js b/spec/frontend_integration/ide/user_opens_mr_spec.js index 9cf0ff5da56..3ffc5169351 100644 --- a/spec/frontend_integration/ide/user_opens_mr_spec.js +++ b/spec/frontend_integration/ide/user_opens_mr_spec.js @@ -24,11 +24,11 @@ describe('IDE: User opens Merge Request', () => { vm = startWebIDE(container, { mrId }); - await ideHelper.waitForTabToOpen(basename(changes[0].new_path)); - await ideHelper.waitForMonacoEditor(); + const editor = await ideHelper.waitForMonacoEditor(); + await ideHelper.waitForEditorModelChange(editor); }); - afterEach(async () => { + afterEach(() => { vm.$destroy(); vm = null; }); diff --git a/spec/frontend_integration/test_helpers/mock_server/graphql.js b/spec/frontend_integration/test_helpers/mock_server/graphql.js index 654c373e5a6..e2658852599 100644 --- a/spec/frontend_integration/test_helpers/mock_server/graphql.js +++ b/spec/frontend_integration/test_helpers/mock_server/graphql.js @@ -1,5 +1,11 @@ import { buildSchema, graphql } from 'graphql'; -import gitlabSchemaStr from '../../../../doc/api/graphql/reference/gitlab_schema.graphql'; + +/* eslint-disable import/no-unresolved */ +// This rule is disabled for the following line. +// The graphql schema is dynamically generated in CI +// during the `graphql-schema-dump` job. +import gitlabSchemaStr from '../../../../tmp/tests/graphql/gitlab_schema.graphql'; +/* eslint-enable import/no-unresolved */ const graphqlSchema = buildSchema(gitlabSchemaStr.loc.source.body); const graphqlResolvers = { diff --git a/spec/generator_helper.rb b/spec/generator_helper.rb new file mode 100644 index 00000000000..d35eaac45bd --- /dev/null +++ b/spec/generator_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.configure do |config| + # Redirect stdout so specs don't have so much noise + config.before(:all) do + $stdout = StringIO.new + end + + # Reset stdout + config.after(:all) do + $stdout = STDOUT + end +end diff --git a/spec/graphql/features/authorization_spec.rb b/spec/graphql/features/authorization_spec.rb index ec67ed16fe9..33b11e1ca09 100644 --- a/spec/graphql/features/authorization_spec.rb +++ b/spec/graphql/features/authorization_spec.rb @@ -2,17 +2,22 @@ require 'spec_helper' -RSpec.describe 'Gitlab::Graphql::Authorization' do +RSpec.describe 'Gitlab::Graphql::Authorize' do include GraphqlHelpers + include Graphql::ResolverFactories let_it_be(:user) { create(:user) } let(:permission_single) { :foo } let(:permission_collection) { [:foo, :bar] } let(:test_object) { double(name: 'My name') } let(:query_string) { '{ item { name } }' } - let(:result) { execute_query(query_type)['data'] } + let(:result) do + schema = empty_schema + schema.use(Gitlab::Graphql::Authorize) + execute_query(query_type, schema: schema) + end - subject { result['item'] } + subject { result.dig('data', 'item') } shared_examples 'authorization with a single permission' do it 'returns the protected field when user has permission' do @@ -55,7 +60,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do describe 'with a single permission' do let(:query_type) do query_factory do |query| - query.field :item, type, null: true, resolver: simple_resolver(test_object), authorize: permission_single + query.field :item, type, null: true, resolver: new_resolver(test_object), authorize: permission_single end end @@ -66,7 +71,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do let(:query_type) do permissions = permission_collection query_factory do |qt| - qt.field :item, type, null: true, resolver: simple_resolver(test_object) do + qt.field :item, type, null: true, resolver: new_resolver(test_object) do authorize permissions end end @@ -79,7 +84,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do describe 'Field authorizations when field is a built in type' do let(:query_type) do query_factory do |query| - query.field :item, type, null: true, resolver: simple_resolver(test_object) + query.field :item, type, null: true, resolver: new_resolver(test_object) end end @@ -132,7 +137,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do describe 'Type authorizations' do let(:query_type) do query_factory do |query| - query.field :item, type, null: true, resolver: simple_resolver(test_object) + query.field :item, type, null: true, resolver: new_resolver(test_object) end end @@ -169,7 +174,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do let(:query_type) do query_factory do |query| - query.field :item, type, null: true, resolver: simple_resolver(test_object), authorize: permission_2 + query.field :item, type, null: true, resolver: new_resolver(test_object), authorize: permission_2 end end @@ -188,11 +193,11 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do let(:query_type) do query_factory do |query| - query.field :item, type.connection_type, null: true, resolver: simple_resolver([test_object, second_test_object]) + query.field :item, type.connection_type, null: true, resolver: new_resolver([test_object, second_test_object]) end end - subject { result.dig('item', 'edges') } + subject { result.dig('data', 'item', 'edges') } it 'returns only the elements visible to the user' do permit(permission_single) @@ -208,7 +213,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do describe 'limiting connections with multiple objects' do let(:query_type) do query_factory do |query| - query.field :item, type.connection_type, null: true, resolver: simple_resolver([test_object, second_test_object]) + query.field :item, type.connection_type, null: true, resolver: new_resolver([test_object, second_test_object]) end end @@ -232,11 +237,11 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do let(:query_type) do query_factory do |query| - query.field :item, [type], null: true, resolver: simple_resolver([test_object]) + query.field :item, [type], null: true, resolver: new_resolver([test_object]) end end - subject { result['item'].first } + subject { result.dig('data', 'item', 0) } include_examples 'authorization with a single permission' end @@ -260,13 +265,13 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do type_factory do |type| type.graphql_name 'FakeProjectType' type.field :test_issues, issue_type.connection_type, null: false, - resolver: simple_resolver(Issue.where(project: [visible_project, other_project]).order(id: :asc)) + resolver: new_resolver(Issue.where(project: [visible_project, other_project]).order(id: :asc)) end end let(:query_type) do query_factory do |query| - query.field :test_project, project_type, null: false, resolver: simple_resolver(visible_project) + query.field :test_project, project_type, null: false, resolver: new_resolver(visible_project) end end @@ -281,7 +286,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do end it 'renders the issues the user has access to' do - issue_edges = result['testProject']['testIssues']['edges'] + issue_edges = result.dig('data', 'testProject', 'testIssues', 'edges') issue_ids = issue_edges.map { |issue_edge| issue_edge['node']&.fetch('id') } expect(issue_edges.size).to eq(visible_issues.size) diff --git a/spec/graphql/features/feature_flag_spec.rb b/spec/graphql/features/feature_flag_spec.rb index 77810f78257..30238cf9cb3 100644 --- a/spec/graphql/features/feature_flag_spec.rb +++ b/spec/graphql/features/feature_flag_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe 'Graphql Field feature flags' do include GraphqlHelpers + include Graphql::ResolverFactories let_it_be(:user) { create(:user) } @@ -23,7 +24,7 @@ RSpec.describe 'Graphql Field feature flags' do let(:query_type) do query_factory do |query| - query.field :item, type, null: true, feature_flag: feature_flag, resolver: simple_resolver(test_object) + query.field :item, type, null: true, feature_flag: feature_flag, resolver: new_resolver(test_object) end end diff --git a/spec/graphql/gitlab_schema_spec.rb b/spec/graphql/gitlab_schema_spec.rb index 4db12643069..cb2bb25b098 100644 --- a/spec/graphql/gitlab_schema_spec.rb +++ b/spec/graphql/gitlab_schema_spec.rb @@ -18,14 +18,6 @@ RSpec.describe GitlabSchema do expect(field_instrumenters).to include(instance_of(::Gitlab::Graphql::Authorize::Instrumentation)) end - it 'enables using presenters' do - expect(field_instrumenters).to include(instance_of(::Gitlab::Graphql::Present::Instrumentation)) - end - - it 'enables using gitaly call checker' do - expect(field_instrumenters).to include(instance_of(::Gitlab::Graphql::CallsGitaly::Instrumentation)) - end - it 'has the base mutation' do expect(described_class.mutation).to eq(::Types::MutationType) end @@ -47,7 +39,7 @@ RSpec.describe GitlabSchema do end describe '.execute' do - context 'for different types of users' do + context 'with different types of users' do context 'when no context' do it 'returns DEFAULT_MAX_COMPLEXITY' do expect(GraphQL::Schema) @@ -78,13 +70,15 @@ RSpec.describe GitlabSchema do context 'when a logged in user' do it 'returns AUTHENTICATED_COMPLEXITY' do - expect(GraphQL::Schema).to receive(:execute).with('query', hash_including(max_complexity: GitlabSchema::AUTHENTICATED_COMPLEXITY)) + expect(GraphQL::Schema).to receive(:execute) + .with('query', hash_including(max_complexity: GitlabSchema::AUTHENTICATED_COMPLEXITY)) described_class.execute('query', context: { current_user: user }) end it 'returns AUTHENTICATED_MAX_DEPTH' do - expect(GraphQL::Schema).to receive(:execute).with('query', hash_including(max_depth: GitlabSchema::AUTHENTICATED_MAX_DEPTH)) + expect(GraphQL::Schema).to receive(:execute) + .with('query', hash_including(max_depth: GitlabSchema::AUTHENTICATED_MAX_DEPTH)) described_class.execute('query', context: { current_user: user }) end @@ -94,7 +88,8 @@ RSpec.describe GitlabSchema do it 'returns ADMIN_COMPLEXITY' do user = build :user, :admin - expect(GraphQL::Schema).to receive(:execute).with('query', hash_including(max_complexity: GitlabSchema::ADMIN_COMPLEXITY)) + expect(GraphQL::Schema).to receive(:execute) + .with('query', hash_including(max_complexity: GitlabSchema::ADMIN_COMPLEXITY)) described_class.execute('query', context: { current_user: user }) end @@ -130,7 +125,7 @@ RSpec.describe GitlabSchema do end describe '.object_from_id' do - context 'for subclasses of `ApplicationRecord`' do + context 'with subclasses of `ApplicationRecord`' do let_it_be(:user) { create(:user) } it 'returns the correct record' do @@ -162,7 +157,7 @@ RSpec.describe GitlabSchema do end end - context 'for classes that are not ActiveRecord subclasses and have implemented .lazy_find' do + context 'with classes that are not ActiveRecord subclasses and have implemented .lazy_find' do it 'returns the correct record' do note = create(:discussion_note_on_merge_request) @@ -182,7 +177,7 @@ RSpec.describe GitlabSchema do end end - context 'for other classes' do + context 'with other classes' do # We cannot use an anonymous class here as `GlobalID` expects `.name` not # to return `nil` before do diff --git a/spec/graphql/mutations/boards/update_spec.rb b/spec/graphql/mutations/boards/update_spec.rb new file mode 100644 index 00000000000..da3dfeecd4d --- /dev/null +++ b/spec/graphql/mutations/boards/update_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Boards::Update do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:board) { create(:board, project: project) } + + let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) } + let(:mutated_board) { subject[:board] } + + let(:mutation_params) do + { + id: board.to_global_id, + hide_backlog_list: true, + hide_closed_list: false + } + end + + subject { mutation.resolve(**mutation_params) } + + specify { expect(described_class).to require_graphql_authorizations(:admin_issue_board) } + + describe '#resolve' do + context 'when the user cannot admin the board' do + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'with invalid params' do + it 'raises an error' do + mutation_params[:id] = project.to_global_id + + expect { subject }.to raise_error(::GraphQL::CoercionError) + end + end + + context 'when user can update board' do + before do + board.resource_parent.add_reporter(user) + end + + it 'updates board with correct values' do + expected_attributes = { + hide_backlog_list: true, + hide_closed_list: false + } + + subject + + expect(board.reload).to have_attributes(expected_attributes) + end + end + end +end diff --git a/spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb b/spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb index ee8db7a1f31..8d1fce406fa 100644 --- a/spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb +++ b/spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb @@ -30,7 +30,7 @@ RSpec.describe Mutations::CanMutateSpammable do end it 'merges in spam action fields from spammable' do - result = subject.send(:with_spam_action_fields, spammable) do + result = subject.send(:with_spam_action_response_fields, spammable) do { other_field: true } end expect(result) diff --git a/spec/graphql/mutations/custom_emoji/create_spec.rb b/spec/graphql/mutations/custom_emoji/create_spec.rb new file mode 100644 index 00000000000..118c5d67188 --- /dev/null +++ b/spec/graphql/mutations/custom_emoji/create_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::CustomEmoji::Create do + let_it_be(:group) { create(:group) } + let_it_be(:user) { create(:user) } + let(:args) { { group_path: group.full_path, name: 'tanuki', url: 'https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png' } } + + before do + group.add_developer(user) + end + + describe '#resolve' do + subject(:resolve) { described_class.new(object: nil, context: { current_user: user }, field: nil).resolve(**args) } + + it 'creates the custom emoji' do + expect { resolve }.to change(CustomEmoji, :count).by(1) + end + + it 'sets the creator to be the user who added the emoji' do + resolve + + expect(CustomEmoji.last.creator).to eq(user) + end + end +end diff --git a/spec/graphql/mutations/merge_requests/accept_spec.rb b/spec/graphql/mutations/merge_requests/accept_spec.rb new file mode 100644 index 00000000000..db75c64a447 --- /dev/null +++ b/spec/graphql/mutations/merge_requests/accept_spec.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::MergeRequests::Accept do + include AfterNextHelpers + + let_it_be(:user) { create(:user) } + let(:project) { create(:project, :public, :repository) } + + subject(:mutation) { described_class.new(context: context, object: nil, field: nil) } + + let_it_be(:context) do + GraphQL::Query::Context.new( + query: OpenStruct.new(schema: GitlabSchema), + values: { current_user: user }, + object: nil + ) + end + + before do + project.repository.expire_all_method_caches + end + + describe '#resolve' do + before do + project.add_maintainer(user) + end + + def common_args(merge_request) + { + project_path: project.full_path, + iid: merge_request.iid.to_s, + sha: merge_request.diff_head_sha, + squash: false # default value + } + end + + it 'merges the merge request' do + merge_request = create(:merge_request, source_project: project) + + result = mutation.resolve(**common_args(merge_request)) + + expect(result).to include(errors: be_empty, merge_request: be_merged) + end + + it 'rejects the mutation if the SHA is a mismatch' do + merge_request = create(:merge_request, source_project: project) + args = common_args(merge_request).merge(sha: 'not a good sha') + + result = mutation.resolve(**args) + + expect(result).not_to include(merge_request: be_merged) + expect(result).to include(errors: [described_class::SHA_MISMATCH]) + end + + it 'respects the merge commit message' do + merge_request = create(:merge_request, source_project: project) + args = common_args(merge_request).merge(commit_message: 'my super custom message') + + result = mutation.resolve(**args) + + expect(result).to include(merge_request: be_merged) + expect(project.repository.commit(merge_request.target_branch)).to have_attributes( + message: args[:commit_message] + ) + end + + it 'respects the squash flag' do + merge_request = create(:merge_request, source_project: project) + args = common_args(merge_request).merge(squash: true) + + result = mutation.resolve(**args) + + expect(result).to include(merge_request: be_merged) + expect(result[:merge_request].squash_commit_sha).to be_present + end + + it 'respects the squash_commit_message argument' do + merge_request = create(:merge_request, source_project: project) + args = common_args(merge_request).merge(squash: true, squash_commit_message: 'squish') + + result = mutation.resolve(**args) + sha = result[:merge_request].squash_commit_sha + + expect(result).to include(merge_request: be_merged) + expect(project.repository.commit(sha)).to have_attributes(message: "squish\n") + end + + it 'respects the should_remove_source_branch argument when true' do + b = project.repository.add_branch(user, generate(:branch), 'master') + merge_request = create(:merge_request, source_branch: b.name, source_project: project) + args = common_args(merge_request).merge(should_remove_source_branch: true) + + expect(::MergeRequests::DeleteSourceBranchWorker).to receive(:perform_async) + + result = mutation.resolve(**args) + + expect(result).to include(merge_request: be_merged) + end + + it 'respects the should_remove_source_branch argument when false' do + b = project.repository.add_branch(user, generate(:branch), 'master') + merge_request = create(:merge_request, source_branch: b.name, source_project: project) + args = common_args(merge_request).merge(should_remove_source_branch: false) + + expect(::MergeRequests::DeleteSourceBranchWorker).not_to receive(:perform_async) + + result = mutation.resolve(**args) + + expect(result).to include(merge_request: be_merged) + end + + it 'rejects unmergeable MRs' do + merge_request = create(:merge_request, :closed, source_project: project) + args = common_args(merge_request) + + result = mutation.resolve(**args) + + expect(result).not_to include(merge_request: be_merged) + expect(result).to include(errors: [described_class::NOT_MERGEABLE]) + end + + it 'rejects merges when we cannot validate the hooks' do + merge_request = create(:merge_request, source_project: project) + args = common_args(merge_request) + expect_next(::MergeRequests::MergeService) + .to receive(:hooks_validation_pass?).with(merge_request).and_return(false) + + result = mutation.resolve(**args) + + expect(result).not_to include(merge_request: be_merged) + expect(result).to include(errors: [described_class::HOOKS_VALIDATION_ERROR]) + end + + it 'rejects merges when the merge service returns an error' do + merge_request = create(:merge_request, source_project: project) + args = common_args(merge_request) + expect_next(::MergeRequests::MergeService) + .to receive(:execute).with(merge_request).and_return(:failed) + + result = mutation.resolve(**args) + + expect(result).not_to include(merge_request: be_merged) + expect(result).to include(errors: [described_class::MERGE_FAILED]) + end + + it 'rejects merges when the merge service raises merge error' do + merge_request = create(:merge_request, source_project: project) + args = common_args(merge_request) + expect_next(::MergeRequests::MergeService) + .to receive(:execute).and_raise(::MergeRequests::MergeBaseService::MergeError, 'boom') + + result = mutation.resolve(**args) + + expect(result).not_to include(merge_request: be_merged) + expect(result).to include(errors: ['boom']) + end + + it "can use the MERGE_WHEN_PIPELINE_SUCCEEDS strategy" do + enum = ::Types::MergeStrategyEnum.values['MERGE_WHEN_PIPELINE_SUCCEEDS'] + merge_request = create(:merge_request, :with_head_pipeline, source_project: project) + args = common_args(merge_request).merge(auto_merge_strategy: enum.value) + + result = mutation.resolve(**args) + + expect(result).not_to include(merge_request: be_merged) + expect(result).to include(errors: be_empty, merge_request: be_auto_merge_enabled) + end + end +end diff --git a/spec/graphql/mutations/release_asset_links/create_spec.rb b/spec/graphql/mutations/release_asset_links/create_spec.rb new file mode 100644 index 00000000000..089bc3d3276 --- /dev/null +++ b/spec/graphql/mutations/release_asset_links/create_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::ReleaseAssetLinks::Create do + include GraphqlHelpers + + let_it_be(:project) { create(:project, :private, :repository) } + let_it_be(:release) { create(:release, project: project, tag: 'v13.10') } + let_it_be(:reporter) { create(:user).tap { |u| project.add_reporter(u) } } + let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } } + + let(:current_user) { developer } + let(:context) { { current_user: current_user } } + let(:project_path) { project.full_path } + let(:tag) { release.tag } + let(:name) { 'awesome-app.dmg' } + let(:url) { 'https://example.com/download/awesome-app.dmg' } + let(:filepath) { '/binaries/awesome-app.dmg' } + + let(:args) do + { + project_path: project_path, + tag_name: tag, + name: name, + direct_asset_path: filepath, + url: url + } + end + + let(:last_release_link) { release.links.last } + + describe '#resolve' do + subject do + resolve(described_class, obj: project, args: args, ctx: context) + end + + context 'when the user has access and no validation errors occur' do + it 'creates a new release asset link', :aggregate_failures do + expect(subject).to eq({ + link: release.reload.links.first, + errors: [] + }) + + expect(release.links.length).to be(1) + + expect(last_release_link.name).to eq(name) + expect(last_release_link.url).to eq(url) + expect(last_release_link.filepath).to eq(filepath) + end + end + + context "when the user doesn't have access to the project" do + let(:current_user) { reporter } + + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context "when the project doesn't exist" do + let(:project_path) { 'project/that/does/not/exist' } + + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context "when a validation errors occur" do + shared_examples 'returns errors-as-data' do |expected_messages| + it { expect(subject[:errors]).to eq(expected_messages) } + end + + context "when the release doesn't exist" do + let(:tag) { "nonexistent-tag" } + + it_behaves_like 'returns errors-as-data', ['Release with tag "nonexistent-tag" was not found'] + end + + context 'when the URL is badly formatted' do + let(:url) { 'badly-formatted-url' } + + it_behaves_like 'returns errors-as-data', ["Url is blocked: Only allowed schemes are http, https, ftp"] + end + + context 'when the name is not provided' do + let(:name) { '' } + + it_behaves_like 'returns errors-as-data', ["Name can't be blank"] + end + + context 'when the link already exists' do + let!(:existing_release_link) do + create(:release_link, release: release, name: name, url: url, filepath: filepath) + end + + it_behaves_like 'returns errors-as-data', [ + "Url has already been taken", + "Name has already been taken", + "Filepath has already been taken" + ] + end + end + end +end diff --git a/spec/graphql/mutations/release_asset_links/update_spec.rb b/spec/graphql/mutations/release_asset_links/update_spec.rb new file mode 100644 index 00000000000..065089066f1 --- /dev/null +++ b/spec/graphql/mutations/release_asset_links/update_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::ReleaseAssetLinks::Update do + include GraphqlHelpers + + let_it_be(:project) { create(:project, :private, :repository) } + let_it_be(:release) { create(:release, project: project, tag: 'v13.10') } + let_it_be(:reporter) { create(:user).tap { |u| project.add_reporter(u) } } + let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } } + + let_it_be(:name) { 'link name' } + let_it_be(:url) { 'https://example.com/url' } + let_it_be(:filepath) { '/permanent/path' } + let_it_be(:link_type) { 'package' } + + let_it_be(:release_link) do + create(:release_link, + release: release, + name: name, + url: url, + filepath: filepath, + link_type: link_type) + end + + let(:current_user) { developer } + let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) } + + let(:mutation_arguments) do + { + id: release_link.to_global_id + } + end + + shared_examples 'no changes to the link except for the' do |except_for| + it 'does not change other link properties' do + expect(updated_link.name).to eq(name) unless except_for == :name + expect(updated_link.url).to eq(url) unless except_for == :url + expect(updated_link.filepath).to eq(filepath) unless except_for == :filepath + expect(updated_link.link_type).to eq(link_type) unless except_for == :link_type + end + end + + shared_examples 'validation error with messages' do |messages| + it 'returns the updated link as nil' do + expect(updated_link).to be_nil + end + + it 'returns a validation error' do + expect(subject[:errors]).to match_array(messages) + end + end + + describe '#ready?' do + let(:current_user) { developer } + + subject(:ready) do + mutation.ready?(**mutation_arguments) + end + + context 'when link_type is included as an argument but is passed nil' do + let(:mutation_arguments) { super().merge(link_type: nil) } + + it 'raises a validation error' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'if the linkType argument is provided, it cannot be null') + end + end + end + + describe '#resolve' do + subject(:resolve) do + mutation.resolve(**mutation_arguments) + end + + let(:updated_link) { subject[:link] } + + context 'when the current user has access to update the link' do + context 'name' do + let(:mutation_arguments) { super().merge(name: updated_name) } + + context 'when a new name is provided' do + let(:updated_name) { 'Updated name' } + + it 'updates the name' do + expect(updated_link.name).to eq(updated_name) + end + + it_behaves_like 'no changes to the link except for the', :name + end + + context 'when nil is provided' do + let(:updated_name) { nil } + + it_behaves_like 'validation error with messages', ["Name can't be blank"] + end + end + + context 'url' do + let(:mutation_arguments) { super().merge(url: updated_url) } + + context 'when a new URL is provided' do + let(:updated_url) { 'https://example.com/updated/link' } + + it 'updates the url' do + expect(updated_link.url).to eq(updated_url) + end + + it_behaves_like 'no changes to the link except for the', :url + end + + context 'when nil is provided' do + let(:updated_url) { nil } + + it_behaves_like 'validation error with messages', ["Url can't be blank", "Url must be a valid URL"] + end + end + + context 'filepath' do + let(:mutation_arguments) { super().merge(filepath: updated_filepath) } + + context 'when a new filepath is provided' do + let(:updated_filepath) { '/updated/filepath' } + + it 'updates the filepath' do + expect(updated_link.filepath).to eq(updated_filepath) + end + + it_behaves_like 'no changes to the link except for the', :filepath + end + + context 'when nil is provided' do + let(:updated_filepath) { nil } + + it 'updates the filepath to nil' do + expect(updated_link.filepath).to be_nil + end + end + end + + context 'link_type' do + let(:mutation_arguments) { super().merge(link_type: updated_link_type) } + + context 'when a new link type is provided' do + let(:updated_link_type) { 'image' } + + it 'updates the link type' do + expect(updated_link.link_type).to eq(updated_link_type) + end + + it_behaves_like 'no changes to the link except for the', :link_type + end + + # Test cases not included: + # - when nil is provided, because this validated by #ready? + # - when an invalid type is provided, because this is validated by the GraphQL schema + end + end + + context 'when the current user does not have access to update the link' do + let(:current_user) { reporter } + + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context "when the link doesn't exist" do + let(:mutation_arguments) { super().merge(id: 'gid://gitlab/Releases::Link/999999') } + + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context "when the provided ID is invalid" do + let(:mutation_arguments) { super().merge(id: 'not-a-valid-gid') } + + it 'raises an error' do + expect { subject }.to raise_error(::GraphQL::CoercionError) + end + end + end +end diff --git a/spec/graphql/mutations/user_callouts/create_spec.rb b/spec/graphql/mutations/user_callouts/create_spec.rb new file mode 100644 index 00000000000..93f227d8b82 --- /dev/null +++ b/spec/graphql/mutations/user_callouts/create_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::UserCallouts::Create do + let(:current_user) { create(:user) } + let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) } + + describe '#resolve' do + subject(:resolve) { mutation.resolve(feature_name: feature_name) } + + context 'when feature name is not supported' do + let(:feature_name) { 'not_supported' } + + it 'does not create a user callout' do + expect { resolve }.not_to change(UserCallout, :count).from(0) + end + + it 'returns error about feature name not being supported' do + expect(resolve[:errors]).to include("Feature name is not included in the list") + end + end + + context 'when feature name is supported' do + let(:feature_name) { UserCallout.feature_names.each_key.first.to_s } + + it 'creates a user callout' do + expect { resolve }.to change(UserCallout, :count).from(0).to(1) + end + + it 'sets dismissed_at for the user callout' do + freeze_time do + expect(resolve[:user_callout].dismissed_at).to eq(Time.current) + end + end + + it 'has no errors' do + expect(resolve[:errors]).to be_empty + end + end + end +end diff --git a/spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb b/spec/graphql/resolvers/admin/analytics/usage_trends/measurements_resolver_spec.rb index 578d679ade4..269a1fb1758 100644 --- a/spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb +++ b/spec/graphql/resolvers/admin/analytics/usage_trends/measurements_resolver_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Resolvers::Admin::Analytics::InstanceStatistics::MeasurementsResolver do +RSpec.describe Resolvers::Admin::Analytics::UsageTrends::MeasurementsResolver do include GraphqlHelpers let_it_be(:admin_user) { create(:user, :admin) } @@ -11,8 +11,8 @@ RSpec.describe Resolvers::Admin::Analytics::InstanceStatistics::MeasurementsReso describe '#resolve' do let_it_be(:user) { create(:user) } - let_it_be(:project_measurement_new) { create(:instance_statistics_measurement, :project_count, recorded_at: 2.days.ago) } - let_it_be(:project_measurement_old) { create(:instance_statistics_measurement, :project_count, recorded_at: 10.days.ago) } + let_it_be(:project_measurement_new) { create(:usage_trends_measurement, :project_count, recorded_at: 2.days.ago) } + let_it_be(:project_measurement_old) { create(:usage_trends_measurement, :project_count, recorded_at: 10.days.ago) } let(:arguments) { { identifier: 'projects' } } @@ -63,8 +63,8 @@ RSpec.describe Resolvers::Admin::Analytics::InstanceStatistics::MeasurementsReso end context 'when requesting pipeline counts by pipeline status' do - let_it_be(:pipelines_succeeded_measurement) { create(:instance_statistics_measurement, :pipelines_succeeded_count, recorded_at: 2.days.ago) } - let_it_be(:pipelines_skipped_measurement) { create(:instance_statistics_measurement, :pipelines_skipped_count, recorded_at: 2.days.ago) } + let_it_be(:pipelines_succeeded_measurement) { create(:usage_trends_measurement, :pipelines_succeeded_count, recorded_at: 2.days.ago) } + let_it_be(:pipelines_skipped_measurement) { create(:usage_trends_measurement, :pipelines_skipped_count, recorded_at: 2.days.ago) } subject { resolve_measurements({ identifier: identifier }, { current_user: current_user }) } diff --git a/spec/graphql/resolvers/alert_management/http_integrations_resolver_spec.rb b/spec/graphql/resolvers/alert_management/http_integrations_resolver_spec.rb new file mode 100644 index 00000000000..2cd61dd7bcf --- /dev/null +++ b/spec/graphql/resolvers/alert_management/http_integrations_resolver_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::AlertManagement::HttpIntegrationsResolver do + include GraphqlHelpers + + let_it_be(:guest) { create(:user) } + let_it_be(:developer) { create(:user) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:prometheus_integration) { create(:prometheus_service, project: project) } + let_it_be(:active_http_integration) { create(:alert_management_http_integration, project: project) } + let_it_be(:inactive_http_integration) { create(:alert_management_http_integration, :inactive, project: project) } + let_it_be(:other_proj_integration) { create(:alert_management_http_integration) } + + subject { sync(resolve_http_integrations) } + + before do + project.add_developer(developer) + project.add_maintainer(maintainer) + end + + specify do + expect(described_class).to have_nullable_graphql_type(Types::AlertManagement::HttpIntegrationType.connection_type) + end + + context 'user does not have permission' do + let(:current_user) { guest } + + it { is_expected.to be_empty } + end + + context 'user has developer permission' do + let(:current_user) { developer } + + it { is_expected.to be_empty } + end + + context 'user has maintainer permission' do + let(:current_user) { maintainer } + + it { is_expected.to contain_exactly(active_http_integration) } + end + + private + + def resolve_http_integrations(args = {}, context = { current_user: current_user }) + resolve(described_class, obj: project, ctx: context) + end +end diff --git a/spec/graphql/resolvers/board_resolver_spec.rb b/spec/graphql/resolvers/board_resolver_spec.rb index c70c696005f..e9c51a536ee 100644 --- a/spec/graphql/resolvers/board_resolver_spec.rb +++ b/spec/graphql/resolvers/board_resolver_spec.rb @@ -14,8 +14,8 @@ RSpec.describe Resolvers::BoardResolver do expect(resolve_board(id: dummy_gid)).to eq nil end - it 'calls Boards::ListService' do - expect_next_instance_of(Boards::ListService) do |service| + it 'calls Boards::BoardsFinder' do + expect_next_instance_of(Boards::BoardsFinder) do |service| expect(service).to receive(:execute).and_return([]) end diff --git a/spec/graphql/resolvers/boards_resolver_spec.rb b/spec/graphql/resolvers/boards_resolver_spec.rb index f121e8a4083..cb3bcb002ec 100644 --- a/spec/graphql/resolvers/boards_resolver_spec.rb +++ b/spec/graphql/resolvers/boards_resolver_spec.rb @@ -12,8 +12,8 @@ RSpec.describe Resolvers::BoardsResolver do expect(resolve_boards).to eq [] end - it 'calls Boards::ListService' do - expect_next_instance_of(Boards::ListService) do |service| + it 'calls Boards::BoardsFinder' do + expect_next_instance_of(Boards::BoardsFinder) do |service| expect(service).to receive(:execute) end diff --git a/spec/graphql/resolvers/branch_commit_resolver_spec.rb b/spec/graphql/resolvers/branch_commit_resolver_spec.rb index 78d4959c3f9..346c9e01088 100644 --- a/spec/graphql/resolvers/branch_commit_resolver_spec.rb +++ b/spec/graphql/resolvers/branch_commit_resolver_spec.rb @@ -12,7 +12,11 @@ RSpec.describe Resolvers::BranchCommitResolver do describe '#resolve' do it 'resolves commit' do - is_expected.to eq(repository.commits('master', limit: 1).last) + expect(sync(commit)).to eq(repository.commits('master', limit: 1).last) + end + + it 'sets project container' do + expect(sync(commit).container).to eq(repository.project) end context 'when branch does not exist' do @@ -22,5 +26,19 @@ RSpec.describe Resolvers::BranchCommitResolver do is_expected.to be_nil end end + + it 'is N+1 safe' do + commit_a = repository.commits('master', limit: 1).last + commit_b = repository.commits('spooky-stuff', limit: 1).last + + commits = batch_sync(max_queries: 1) do + [ + resolve(described_class, obj: branch), + resolve(described_class, obj: repository.find_branch('spooky-stuff')) + ] + end + + expect(commits).to contain_exactly(commit_a, commit_b) + end end end diff --git a/spec/graphql/resolvers/concerns/caching_array_resolver_spec.rb b/spec/graphql/resolvers/concerns/caching_array_resolver_spec.rb index 5370f7a7433..e9e7fff6e6e 100644 --- a/spec/graphql/resolvers/concerns/caching_array_resolver_spec.rb +++ b/spec/graphql/resolvers/concerns/caching_array_resolver_spec.rb @@ -9,7 +9,6 @@ RSpec.describe ::CachingArrayResolver do let_it_be(:admins) { create_list(:user, 4, admin: true) } let(:query_context) { { current_user: admins.first } } let(:max_page_size) { 10 } - let(:field) { double('Field', max_page_size: max_page_size) } let(:schema) do Class.new(GitlabSchema) do default_max_page_size 3 @@ -210,6 +209,6 @@ RSpec.describe ::CachingArrayResolver do args = { is_admin: admin } opts = resolver.field_options allow(resolver).to receive(:field_options).and_return(opts.merge(max_page_size: max_page_size)) - resolve(resolver, args: args, ctx: query_context, schema: schema, field: field) + resolve(resolver, args: args, ctx: query_context, schema: schema) end end diff --git a/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb b/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb index 170a602fb0d..68badb8e333 100644 --- a/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb +++ b/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb @@ -19,7 +19,7 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorsResolver do end describe '#resolve' do - context 'insufficient user permission' do + context 'with insufficient user permission' do let(:user) { create(:user) } it 'returns nil' do @@ -29,7 +29,7 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorsResolver do end end - context 'user with permission' do + context 'with sufficient permission' do before do project.add_developer(current_user) @@ -93,7 +93,7 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorsResolver do end it 'returns an externally paginated array' do - expect(resolve_errors).to be_a Gitlab::Graphql::ExternallyPaginatedArray + expect(resolve_errors).to be_a Gitlab::Graphql::Pagination::ExternallyPaginatedArrayConnection end end end diff --git a/spec/graphql/resolvers/group_labels_resolver_spec.rb b/spec/graphql/resolvers/group_labels_resolver_spec.rb index ed94f12502a..3f4ad8760c0 100644 --- a/spec/graphql/resolvers/group_labels_resolver_spec.rb +++ b/spec/graphql/resolvers/group_labels_resolver_spec.rb @@ -42,7 +42,7 @@ RSpec.describe Resolvers::GroupLabelsResolver do context 'without parent' do it 'returns no labels' do - expect(resolve_labels(nil)).to eq(Label.none) + expect(resolve_labels(nil)).to be_empty end end diff --git a/spec/graphql/resolvers/group_packages_resolver_spec.rb b/spec/graphql/resolvers/group_packages_resolver_spec.rb new file mode 100644 index 00000000000..59438b8d5ad --- /dev/null +++ b/spec/graphql/resolvers/group_packages_resolver_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::GroupPackagesResolver do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group, :public) } + let_it_be(:project) { create(:project, :public, group: group) } + let_it_be(:package) { create(:package, project: project) } + + describe '#resolve' do + subject(:packages) { resolve(described_class, ctx: { current_user: user }, obj: group) } + + it { is_expected.to contain_exactly(package) } + end +end diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb index 8980f4aa19d..6e802bf7d25 100644 --- a/spec/graphql/resolvers/issues_resolver_spec.rb +++ b/spec/graphql/resolvers/issues_resolver_spec.rb @@ -264,7 +264,7 @@ RSpec.describe Resolvers::IssuesResolver do end it 'finds a specific issue with iid', :request_store do - result = batch_sync(max_queries: 4) { resolve_issues(iid: issue1.iid) } + result = batch_sync(max_queries: 4) { resolve_issues(iid: issue1.iid).to_a } expect(result).to contain_exactly(issue1) end @@ -281,7 +281,7 @@ RSpec.describe Resolvers::IssuesResolver do it 'finds a specific issue with iids', :request_store do result = batch_sync(max_queries: 4) do - resolve_issues(iids: [issue1.iid]) + resolve_issues(iids: [issue1.iid]).to_a end expect(result).to contain_exactly(issue1) @@ -290,7 +290,7 @@ RSpec.describe Resolvers::IssuesResolver do it 'finds multiple issues with iids' do create(:issue, project: project, author: current_user) - expect(batch_sync { resolve_issues(iids: [issue1.iid, issue2.iid]) }) + expect(batch_sync { resolve_issues(iids: [issue1.iid, issue2.iid]).to_a }) .to contain_exactly(issue1, issue2) end @@ -302,7 +302,7 @@ RSpec.describe Resolvers::IssuesResolver do create(:issue, project: another_project, iid: iid) end - expect(batch_sync { resolve_issues(iids: iids) }).to contain_exactly(issue1, issue2) + expect(batch_sync { resolve_issues(iids: iids).to_a }).to contain_exactly(issue1, issue2) end end end diff --git a/spec/graphql/resolvers/labels_resolver_spec.rb b/spec/graphql/resolvers/labels_resolver_spec.rb index 3d027a6c8d5..be6229553d7 100644 --- a/spec/graphql/resolvers/labels_resolver_spec.rb +++ b/spec/graphql/resolvers/labels_resolver_spec.rb @@ -42,50 +42,36 @@ RSpec.describe Resolvers::LabelsResolver do context 'without parent' do it 'returns no labels' do - expect(resolve_labels(nil)).to eq(Label.none) + expect(resolve_labels(nil)).to be_empty end end - context 'at project level' do + context 'with a parent project' do before_all do group.add_developer(current_user) end - # because :include_ancestor_groups, :include_descendant_groups, :only_group_labels default to false - # the `nil` value would be equivalent to passing in `false` so just check for `nil` option - where(:include_ancestor_groups, :include_descendant_groups, :only_group_labels, :search_term, :test) do - nil | nil | nil | nil | -> { expect(subject).to contain_exactly(label1, label2, subgroup_label1, subgroup_label2) } - nil | nil | true | nil | -> { expect(subject).to contain_exactly(label1, label2, subgroup_label1, subgroup_label2) } - nil | true | nil | nil | -> { expect(subject).to contain_exactly(label1, label2, subgroup_label1, subgroup_label2, sub_subgroup_label1, sub_subgroup_label2) } - nil | true | true | nil | -> { expect(subject).to contain_exactly(label1, label2, subgroup_label1, subgroup_label2, sub_subgroup_label1, sub_subgroup_label2) } - true | nil | nil | nil | -> { expect(subject).to contain_exactly(label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2) } - true | nil | true | nil | -> { expect(subject).to contain_exactly(label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2) } - true | true | nil | nil | -> { expect(subject).to contain_exactly(label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2, sub_subgroup_label1, sub_subgroup_label2) } - true | true | true | nil | -> { expect(subject).to contain_exactly(label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2, sub_subgroup_label1, sub_subgroup_label2) } - - nil | nil | nil | 'new' | -> { expect(subject).to contain_exactly(label2, subgroup_label2) } - nil | nil | true | 'new' | -> { expect(subject).to contain_exactly(label2, subgroup_label2) } - nil | true | nil | 'new' | -> { expect(subject).to contain_exactly(label2, subgroup_label2, sub_subgroup_label2) } - nil | true | true | 'new' | -> { expect(subject).to contain_exactly(label2, subgroup_label2, sub_subgroup_label2) } - true | nil | nil | 'new' | -> { expect(subject).to contain_exactly(label2, group_label2, subgroup_label2) } - true | nil | true | 'new' | -> { expect(subject).to contain_exactly(label2, group_label2, subgroup_label2) } - true | true | nil | 'new' | -> { expect(subject).to contain_exactly(label2, group_label2, subgroup_label2, sub_subgroup_label2) } - true | true | true | 'new' | -> { expect(subject).to contain_exactly(label2, group_label2, subgroup_label2, sub_subgroup_label2) } + # the expected result is wrapped in a lambda to get around the phase restrictions of RSpec::Parameterized + where(:include_ancestor_groups, :search_term, :expected_labels) do + nil | nil | -> { [label1, label2, subgroup_label1, subgroup_label2] } + false | nil | -> { [label1, label2, subgroup_label1, subgroup_label2] } + true | nil | -> { [label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2] } + nil | 'new' | -> { [label2, subgroup_label2] } + false | 'new' | -> { [label2, subgroup_label2] } + true | 'new' | -> { [label2, group_label2, subgroup_label2] } end with_them do let(:params) do { include_ancestor_groups: include_ancestor_groups, - include_descendant_groups: include_descendant_groups, - only_group_labels: only_group_labels, search_term: search_term } end subject { resolve_labels(project, params) } - it { self.instance_exec(&test) } + specify { expect(subject).to match_array(instance_exec(&expected_labels)) } end end end diff --git a/spec/graphql/resolvers/merge_requests_resolver_spec.rb b/spec/graphql/resolvers/merge_requests_resolver_spec.rb index c5c368fc88f..7dd968d90a8 100644 --- a/spec/graphql/resolvers/merge_requests_resolver_spec.rb +++ b/spec/graphql/resolvers/merge_requests_resolver_spec.rb @@ -69,7 +69,7 @@ RSpec.describe Resolvers::MergeRequestsResolver do it 'batch-resolves by target project full path and IIDS', :request_store do result = batch_sync(max_queries: queries_per_project) do - resolve_mr(project, iids: [iid_1, iid_2]) + resolve_mr(project, iids: [iid_1, iid_2]).to_a end expect(result).to contain_exactly(merge_request_1, merge_request_2) diff --git a/spec/graphql/resolvers/namespace_projects_resolver_spec.rb b/spec/graphql/resolvers/namespace_projects_resolver_spec.rb index 4ad8f99219f..147a02e1d79 100644 --- a/spec/graphql/resolvers/namespace_projects_resolver_spec.rb +++ b/spec/graphql/resolvers/namespace_projects_resolver_spec.rb @@ -6,6 +6,18 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do include GraphqlHelpers let(:current_user) { create(:user) } + let(:include_subgroups) { true } + let(:sort) { nil } + let(:search) { nil } + let(:ids) { nil } + let(:args) do + { + include_subgroups: include_subgroups, + sort: sort, + search: search, + ids: ids + } + end context "with a group" do let(:group) { create(:group) } @@ -27,7 +39,7 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do end it 'finds all projects including the subgroups' do - expect(resolve_projects(include_subgroups: true, sort: nil, search: nil)).to contain_exactly(project1, project2, nested_project) + expect(resolve_projects(args)).to contain_exactly(project1, project2, nested_project) end context 'with an user namespace' do @@ -38,7 +50,7 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do end it 'finds all projects including the subgroups' do - expect(resolve_projects(include_subgroups: true, sort: nil, search: nil)).to contain_exactly(project1, project2) + expect(resolve_projects(args)).to contain_exactly(project1, project2) end end end @@ -48,6 +60,9 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do let(:project_2) { create(:project, name: 'Test Project', path: 'test-project', namespace: namespace) } let(:project_3) { create(:project, name: 'Test', path: 'test', namespace: namespace) } + let(:sort) { :similarity } + let(:search) { 'test' } + before do project_1.add_developer(current_user) project_2.add_developer(current_user) @@ -55,7 +70,7 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do end it 'returns projects ordered by similarity to the search input' do - projects = resolve_projects(include_subgroups: true, sort: :similarity, search: 'test') + projects = resolve_projects(args) project_names = projects.map { |proj| proj['name'] } expect(project_names.first).to eq('Test') @@ -63,15 +78,17 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do end it 'filters out result that do not match the search input' do - projects = resolve_projects(include_subgroups: true, sort: :similarity, search: 'test') + projects = resolve_projects(args) project_names = projects.map { |proj| proj['name'] } expect(project_names).not_to include('Project') end context 'when `search` parameter is not given' do + let(:search) { nil } + it 'returns projects not ordered by similarity' do - projects = resolve_projects(include_subgroups: true, sort: :similarity, search: nil) + projects = resolve_projects(args) project_names = projects.map { |proj| proj['name'] } expect(project_names.first).not_to eq('Test') @@ -79,14 +96,40 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do end context 'when only search term is given' do + let(:sort) { nil } + let(:search) { 'test' } + it 'filters out result that do not match the search input, but does not sort them' do - projects = resolve_projects(include_subgroups: true, sort: :nil, search: 'test') + projects = resolve_projects(args) project_names = projects.map { |proj| proj['name'] } expect(project_names).to contain_exactly('Test', 'Test Project') end end end + + context 'ids filtering' do + subject(:projects) { resolve_projects(args) } + + let(:include_subgroups) { false } + let(:project_3) { create(:project, name: 'Project', path: 'project', namespace: namespace) } + + context 'when ids is provided' do + let(:ids) { [project_3.to_global_id.to_s] } + + it 'returns matching project' do + expect(projects).to contain_exactly(project_3) + end + end + + context 'when ids is nil' do + let(:ids) { nil } + + it 'returns all projects' do + expect(projects).to contain_exactly(project1, project2, project_3) + end + end + end end context "when passing a non existent, batch loaded namespace" do @@ -108,7 +151,7 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do expect(field.to_graphql.complexity.call({}, { include_subgroups: true }, 1)).to eq 24 end - def resolve_projects(args = { include_subgroups: false, sort: nil, search: nil }, context = { current_user: current_user }) + def resolve_projects(args = { include_subgroups: false, sort: nil, search: nil, ids: nil }, context = { current_user: current_user }) resolve(described_class, obj: namespace, args: args, ctx: context) end end diff --git a/spec/graphql/resolvers/packages_resolver_spec.rb b/spec/graphql/resolvers/project_packages_resolver_spec.rb index bc0588daf7f..c8105ed2a38 100644 --- a/spec/graphql/resolvers/packages_resolver_spec.rb +++ b/spec/graphql/resolvers/project_packages_resolver_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Resolvers::PackagesResolver do +RSpec.describe Resolvers::ProjectPackagesResolver do include GraphqlHelpers let_it_be(:user) { create(:user) } diff --git a/spec/graphql/resolvers/project_pipeline_resolver_spec.rb b/spec/graphql/resolvers/project_pipeline_resolver_spec.rb index b852b349d4f..69127c4b061 100644 --- a/spec/graphql/resolvers/project_pipeline_resolver_spec.rb +++ b/spec/graphql/resolvers/project_pipeline_resolver_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Resolvers::ProjectPipelineResolver do include GraphqlHelpers let_it_be(:project) { create(:project) } - let_it_be(:pipeline) { create(:ci_pipeline, project: project, iid: '1234') } + let_it_be(:pipeline) { create(:ci_pipeline, project: project, iid: '1234', sha: 'sha') } let_it_be(:other_pipeline) { create(:ci_pipeline) } let(:current_user) { create(:user) } @@ -30,7 +30,15 @@ RSpec.describe Resolvers::ProjectPipelineResolver do expect(result).to eq(pipeline) end - it 'keeps the queries under the threshold' do + it 'resolves pipeline for the passed sha' do + result = batch_sync do + resolve_pipeline(project, { sha: 'sha' }) + end + + expect(result).to eq(pipeline) + end + + it 'keeps the queries under the threshold for iid' do create(:ci_pipeline, project: project, iid: '1235') control = ActiveRecord::QueryRecorder.new do @@ -45,6 +53,21 @@ RSpec.describe Resolvers::ProjectPipelineResolver do end.not_to exceed_query_limit(control) end + it 'keeps the queries under the threshold for sha' do + create(:ci_pipeline, project: project, sha: 'sha2') + + control = ActiveRecord::QueryRecorder.new do + batch_sync { resolve_pipeline(project, { sha: 'sha' }) } + end + + expect do + batch_sync do + resolve_pipeline(project, { sha: 'sha' }) + resolve_pipeline(project, { sha: 'sha2' }) + end + end.not_to exceed_query_limit(control) + end + it 'does not resolve a pipeline outside the project' do result = batch_sync do resolve_pipeline(other_pipeline.project, { iid: '1234' }) @@ -53,8 +76,14 @@ RSpec.describe Resolvers::ProjectPipelineResolver do expect(result).to be_nil end - it 'errors when no iid is passed' do - expect { resolve_pipeline(project, {}) }.to raise_error(ArgumentError) + it 'errors when no iid or sha is passed' do + expect { resolve_pipeline(project, {}) } + .to raise_error(Gitlab::Graphql::Errors::ArgumentError) + end + + it 'errors when both iid and sha are passed' do + expect { resolve_pipeline(project, { iid: '1234', sha: 'sha' }) } + .to raise_error(Gitlab::Graphql::Errors::ArgumentError) end context 'when the pipeline is a dangling pipeline' do diff --git a/spec/graphql/resolvers/release_milestones_resolver_spec.rb b/spec/graphql/resolvers/release_milestones_resolver_spec.rb index f05069998d0..a5a523859f9 100644 --- a/spec/graphql/resolvers/release_milestones_resolver_spec.rb +++ b/spec/graphql/resolvers/release_milestones_resolver_spec.rb @@ -14,7 +14,7 @@ RSpec.describe Resolvers::ReleaseMilestonesResolver do describe '#resolve' do it "uses offset-pagination" do - expect(resolved).to be_a(::Gitlab::Graphql::Pagination::OffsetPaginatedRelation) + expect(resolved).to be_a(::Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection) end it "includes the release's milestones in the returned OffsetActiveRecordRelationConnection" do diff --git a/spec/graphql/types/access_level_enum_spec.rb b/spec/graphql/types/access_level_enum_spec.rb index eeb10a50b7e..1b379c56ff9 100644 --- a/spec/graphql/types/access_level_enum_spec.rb +++ b/spec/graphql/types/access_level_enum_spec.rb @@ -6,6 +6,6 @@ RSpec.describe GitlabSchema.types['AccessLevelEnum'] do specify { expect(described_class.graphql_name).to eq('AccessLevelEnum') } it 'exposes all the existing access levels' do - expect(described_class.values.keys).to match_array(%w[NO_ACCESS GUEST REPORTER DEVELOPER MAINTAINER OWNER]) + expect(described_class.values.keys).to match_array(%w[NO_ACCESS MINIMAL_ACCESS GUEST REPORTER DEVELOPER MAINTAINER OWNER]) end end diff --git a/spec/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum_spec.rb b/spec/graphql/types/admin/analytics/usage_trends/measurement_identifier_enum_spec.rb index 8a7408224a2..91851c11dc8 100644 --- a/spec/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum_spec.rb +++ b/spec/graphql/types/admin/analytics/usage_trends/measurement_identifier_enum_spec.rb @@ -7,7 +7,7 @@ RSpec.describe GitlabSchema.types['MeasurementIdentifier'] do it 'exposes all the existing identifier values' do ee_only_identifiers = %w[billable_users] - identifiers = Analytics::InstanceStatistics::Measurement.identifiers.keys.reject do |x| + identifiers = Analytics::UsageTrends::Measurement.identifiers.keys.reject do |x| ee_only_identifiers.include?(x) end.map(&:upcase) diff --git a/spec/graphql/types/admin/analytics/instance_statistics/measurement_type_spec.rb b/spec/graphql/types/admin/analytics/usage_trends/measurement_type_spec.rb index ffb1a0f30c9..c50092d7f0e 100644 --- a/spec/graphql/types/admin/analytics/instance_statistics/measurement_type_spec.rb +++ b/spec/graphql/types/admin/analytics/usage_trends/measurement_type_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe GitlabSchema.types['InstanceStatisticsMeasurement'] do +RSpec.describe GitlabSchema.types['UsageTrendsMeasurement'] do subject { described_class } it { is_expected.to have_graphql_field(:recorded_at) } @@ -10,13 +10,13 @@ RSpec.describe GitlabSchema.types['InstanceStatisticsMeasurement'] do it { is_expected.to have_graphql_field(:count) } describe 'authorization' do - let_it_be(:measurement) { create(:instance_statistics_measurement, :project_count) } + let_it_be(:measurement) { create(:usage_trends_measurement, :project_count) } let(:user) { create(:user) } let(:query) do <<~GRAPHQL - query instanceStatisticsMeasurements($identifier: MeasurementIdentifier!) { - instanceStatisticsMeasurements(identifier: $identifier) { + query usageTrendsMeasurements($identifier: MeasurementIdentifier!) { + usageTrendsMeasurements(identifier: $identifier) { nodes { count identifier @@ -36,7 +36,7 @@ RSpec.describe GitlabSchema.types['InstanceStatisticsMeasurement'] do context 'when the user is not admin' do it 'returns no data' do - expect(subject.dig('data', 'instanceStatisticsMeasurements')).to be_nil + expect(subject.dig('data', 'usageTrendsMeasurements')).to be_nil end end @@ -48,7 +48,7 @@ RSpec.describe GitlabSchema.types['InstanceStatisticsMeasurement'] do end it 'returns data' do - expect(subject.dig('data', 'instanceStatisticsMeasurements', 'nodes')).not_to be_empty + expect(subject.dig('data', 'usageTrendsMeasurements', 'nodes')).not_to be_empty end end end diff --git a/spec/graphql/types/alert_management/alert_type_spec.rb b/spec/graphql/types/alert_management/alert_type_spec.rb index 82b48a20708..9ff01418c9a 100644 --- a/spec/graphql/types/alert_management/alert_type_spec.rb +++ b/spec/graphql/types/alert_management/alert_type_spec.rb @@ -10,7 +10,8 @@ RSpec.describe GitlabSchema.types['AlertManagementAlert'] do it 'exposes the expected fields' do expected_fields = %i[ iid - issue_iid + issueIid + issue title description severity diff --git a/spec/graphql/types/base_argument_spec.rb b/spec/graphql/types/base_argument_spec.rb new file mode 100644 index 00000000000..61e0179ff21 --- /dev/null +++ b/spec/graphql/types/base_argument_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::BaseArgument do + include_examples 'Gitlab-style deprecations' do + let_it_be(:field) do + Types::BaseField.new(name: 'field', type: String, null: true) + end + + let(:base_args) { { name: 'test', type: String, required: false, owner: field } } + + def subject(args = {}) + described_class.new(**base_args.merge(args)) + end + end +end diff --git a/spec/graphql/types/board_type_spec.rb b/spec/graphql/types/board_type_spec.rb index 5ea87d5f473..dca3cfd8aaf 100644 --- a/spec/graphql/types/board_type_spec.rb +++ b/spec/graphql/types/board_type_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['Board'] do specify { expect(described_class.graphql_name).to eq('Board') } - specify { expect(described_class).to require_graphql_authorizations(:read_board) } + specify { expect(described_class).to require_graphql_authorizations(:read_issue_board) } it 'has specific fields' do expected_fields = %w[id name web_url web_path] diff --git a/spec/graphql/types/ci/job_type_spec.rb b/spec/graphql/types/ci/job_type_spec.rb index e277916f5cb..25f626cea0f 100644 --- a/spec/graphql/types/ci/job_type_spec.rb +++ b/spec/graphql/types/ci/job_type_spec.rb @@ -14,6 +14,8 @@ RSpec.describe Types::Ci::JobType do detailedStatus scheduledAt artifacts + finished_at + duration ] expect(described_class).to have_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/ci/pipeline_type_spec.rb b/spec/graphql/types/ci/pipeline_type_spec.rb index 2a1e030480d..e0e84a1b635 100644 --- a/spec/graphql/types/ci/pipeline_type_spec.rb +++ b/spec/graphql/types/ci/pipeline_type_spec.rb @@ -12,11 +12,11 @@ RSpec.describe Types::Ci::PipelineType do id iid sha before_sha status detailed_status config_source duration coverage created_at updated_at started_at finished_at committed_at stages user retryable cancelable jobs source_job downstream - upstream path project active user_permissions warnings + upstream path project active user_permissions warnings commit_path ] if Gitlab.ee? - expected_fields << 'security_report_summary' + expected_fields += %w[security_report_summary security_report_findings] end expect(described_class).to have_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/global_id_type_spec.rb b/spec/graphql/types/global_id_type_spec.rb index cb129868f7e..8eb023ad2a3 100644 --- a/spec/graphql/types/global_id_type_spec.rb +++ b/spec/graphql/types/global_id_type_spec.rb @@ -5,7 +5,6 @@ require 'spec_helper' RSpec.describe Types::GlobalIDType do let_it_be(:project) { create(:project) } let(:gid) { project.to_global_id } - let(:foreign_gid) { GlobalID.new(::URI::GID.build(app: 'otherapp', model_name: 'Project', model_id: project.id, params: nil)) } it 'is has the correct name' do expect(described_class.to_graphql.name).to eq('GlobalID') @@ -41,16 +40,18 @@ RSpec.describe Types::GlobalIDType do it 'rejects invalid input' do expect { described_class.coerce_isolated_input('not valid') } - .to raise_error(GraphQL::CoercionError) + .to raise_error(GraphQL::CoercionError, /not a valid Global ID/) end it 'rejects nil' do expect(described_class.coerce_isolated_input(nil)).to be_nil end - it 'rejects gids from different apps' do - expect { described_class.coerce_isolated_input(foreign_gid) } - .to raise_error(GraphQL::CoercionError) + it 'rejects GIDs from different apps' do + invalid_gid = GlobalID.new(::URI::GID.build(app: 'otherapp', model_name: 'Project', model_id: project.id, params: nil)) + + expect { described_class.coerce_isolated_input(invalid_gid) } + .to raise_error(GraphQL::CoercionError, /is not a Gitlab Global ID/) end end @@ -79,14 +80,22 @@ RSpec.describe Types::GlobalIDType do let(:gid) { build_stubbed(:user).to_global_id } it 'raises errors when coercing results' do - expect { type.coerce_isolated_result(gid) }.to raise_error(GraphQL::CoercionError) + expect { type.coerce_isolated_result(gid) } + .to raise_error(GraphQL::CoercionError, /Expected a Project ID/) end it 'will not coerce invalid input, even if its a valid GID' do expect { type.coerce_isolated_input(gid.to_s) } - .to raise_error(GraphQL::CoercionError) + .to raise_error(GraphQL::CoercionError, /does not represent an instance of Project/) end end + + it 'handles GIDs for invalid resource names gracefully' do + invalid_gid = GlobalID.new(::URI::GID.build(app: GlobalID.app, model_name: 'invalid', model_id: 1, params: nil)) + + expect { type.coerce_isolated_input(invalid_gid) } + .to raise_error(GraphQL::CoercionError, /does not represent an instance of Project/) + end end describe 'a parameterized type with a namespace' do diff --git a/spec/graphql/types/group_type_spec.rb b/spec/graphql/types/group_type_spec.rb index bba702ba3e9..ef11e3d309c 100644 --- a/spec/graphql/types/group_type_spec.rb +++ b/spec/graphql/types/group_type_spec.rb @@ -18,6 +18,7 @@ RSpec.describe GitlabSchema.types['Group'] do two_factor_grace_period auto_devops_enabled emails_disabled mentions_disabled parent boards milestones group_members merge_requests container_repositories container_repositories_count + packages ] expect(described_class).to include_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/label_type_spec.rb b/spec/graphql/types/label_type_spec.rb index 6a999a2e925..427b5d2dcef 100644 --- a/spec/graphql/types/label_type_spec.rb +++ b/spec/graphql/types/label_type_spec.rb @@ -3,7 +3,16 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['Label'] do it 'has the correct fields' do - expected_fields = [:id, :description, :description_html, :title, :color, :text_color] + expected_fields = [ + :id, + :description, + :description_html, + :title, + :color, + :text_color, + :created_at, + :updated_at + ] expect(described_class).to have_graphql_fields(*expected_fields) end diff --git a/spec/graphql/types/merge_request_type_spec.rb b/spec/graphql/types/merge_request_type_spec.rb index 63d288934e5..3314ea62324 100644 --- a/spec/graphql/types/merge_request_type_spec.rb +++ b/spec/graphql/types/merge_request_type_spec.rb @@ -23,7 +23,7 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do merge_error allow_collaboration should_be_rebased rebase_commit_sha rebase_in_progress default_merge_commit_message merge_ongoing mergeable_discussions_state web_url - source_branch_exists target_branch_exists + source_branch_exists target_branch_exists diverged_from_target_branch upvotes downvotes head_pipeline pipelines task_completion_status milestone assignees reviewers participants subscribed labels discussion_locked time_estimate total_time_spent reference author merged_at commit_count current_user_todos @@ -77,4 +77,33 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do end end end + + describe '#diverged_from_target_branch' do + subject(:execute_query) { GitlabSchema.execute(query, context: { current_user: current_user }).as_json } + + let!(:merge_request) { create(:merge_request, target_project: project, source_project: project) } + let(:project) { create(:project, :public) } + let(:current_user) { create :admin } + let(:query) do + %( + { + project(fullPath: "#{project.full_path}") { + mergeRequests { + nodes { + divergedFromTargetBranch + } + } + } + } + ) + end + + it 'delegates the diverged_from_target_branch? call to the merge request entity' do + expect_next_found_instance_of(MergeRequest) do |instance| + expect(instance).to receive(:diverged_from_target_branch?) + end + + execute_query + end + end end diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb index fea0a3bd37e..cb8e875dbf4 100644 --- a/spec/graphql/types/query_type_spec.rb +++ b/spec/graphql/types/query_type_spec.rb @@ -21,7 +21,7 @@ RSpec.describe GitlabSchema.types['Query'] do user users issue - instance_statistics_measurements + usage_trends_measurements runner_platforms ] @@ -65,11 +65,11 @@ RSpec.describe GitlabSchema.types['Query'] do end end - describe 'instance_statistics_measurements field' do - subject { described_class.fields['instanceStatisticsMeasurements'] } + describe 'usage_trends_measurements field' do + subject { described_class.fields['usageTrendsMeasurements'] } - it 'returns instance statistics measurements' do - is_expected.to have_graphql_type(Types::Admin::Analytics::InstanceStatistics::MeasurementType.connection_type) + it 'returns usage trends measurements' do + is_expected.to have_graphql_type(Types::Admin::Analytics::UsageTrends::MeasurementType.connection_type) end end diff --git a/spec/graphql/types/snippet_type_spec.rb b/spec/graphql/types/snippet_type_spec.rb index e73665a1b1d..4d827186a9b 100644 --- a/spec/graphql/types/snippet_type_spec.rb +++ b/spec/graphql/types/snippet_type_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['Snippet'] do + include GraphqlHelpers + let_it_be(:user) { create(:user) } it 'has the correct fields' do @@ -25,6 +27,14 @@ RSpec.describe GitlabSchema.types['Snippet'] do end end + describe '#user_permissions' do + let_it_be(:snippet) { create(:personal_snippet, :repository, :public, author: user) } + + it 'can resolve the snippet permissions' do + expect(resolve_field(:user_permissions, snippet)).to eq(snippet) + end + end + context 'when restricted visibility level is set to public' do let_it_be(:snippet) { create(:personal_snippet, :repository, :public, author: user) } diff --git a/spec/graphql/types/snippets/blob_type_spec.rb b/spec/graphql/types/snippets/blob_type_spec.rb index bfac08f40d3..60c0db8e551 100644 --- a/spec/graphql/types/snippets/blob_type_spec.rb +++ b/spec/graphql/types/snippets/blob_type_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['SnippetBlob'] do + include GraphqlHelpers + it 'has the correct fields' do expected_fields = [:rich_data, :plain_data, :raw_path, :size, :binary, :name, :path, @@ -12,16 +14,37 @@ RSpec.describe GitlabSchema.types['SnippetBlob'] do expect(described_class).to have_graphql_fields(*expected_fields) end - specify { expect(described_class.fields['richData'].type).not_to be_non_null } - specify { expect(described_class.fields['plainData'].type).not_to be_non_null } - specify { expect(described_class.fields['rawPath'].type).to be_non_null } - specify { expect(described_class.fields['size'].type).to be_non_null } - specify { expect(described_class.fields['binary'].type).to be_non_null } - specify { expect(described_class.fields['name'].type).not_to be_non_null } - specify { expect(described_class.fields['path'].type).not_to be_non_null } - specify { expect(described_class.fields['simpleViewer'].type).to be_non_null } - specify { expect(described_class.fields['richViewer'].type).not_to be_non_null } - specify { expect(described_class.fields['mode'].type).not_to be_non_null } - specify { expect(described_class.fields['externalStorage'].type).not_to be_non_null } - specify { expect(described_class.fields['renderedAsText'].type).to be_non_null } + let_it_be(:nullity) do + { + 'richData' => be_nullable, + 'plainData' => be_nullable, + 'rawPath' => be_non_null, + 'size' => be_non_null, + 'binary' => be_non_null, + 'name' => be_nullable, + 'path' => be_nullable, + 'simpleViewer' => be_non_null, + 'richViewer' => be_nullable, + 'mode' => be_nullable, + 'externalStorage' => be_nullable, + 'renderedAsText' => be_non_null + } + end + + let_it_be(:blob) { create(:snippet, :public, :repository).blobs.first } + + shared_examples 'a field from the snippet blob presenter' do |field| + it "resolves using the presenter", :request_store do + presented = SnippetBlobPresenter.new(blob) + + expect(resolve_field(field, blob)).to eq(presented.try(field.method_sym)) + end + end + + described_class.fields.each_value do |field| + describe field.graphql_name do + it_behaves_like 'a field from the snippet blob presenter', field + specify { expect(field.type).to match(nullity.fetch(field.graphql_name)) } + end + end end diff --git a/spec/graphql/types/user_callout_feature_name_enum_spec.rb b/spec/graphql/types/user_callout_feature_name_enum_spec.rb new file mode 100644 index 00000000000..28755e1301b --- /dev/null +++ b/spec/graphql/types/user_callout_feature_name_enum_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['UserCalloutFeatureNameEnum'] do + specify { expect(described_class.graphql_name).to eq('UserCalloutFeatureNameEnum') } + + it 'exposes all the existing user callout feature names' do + expect(described_class.values.keys).to match_array(::UserCallout.feature_names.keys.map(&:upcase)) + end +end diff --git a/spec/graphql/types/user_callout_type_spec.rb b/spec/graphql/types/user_callout_type_spec.rb new file mode 100644 index 00000000000..b26b85a4e8b --- /dev/null +++ b/spec/graphql/types/user_callout_type_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['UserCallout'] do + specify { expect(described_class.graphql_name).to eq('UserCallout') } + + it 'has expected fields' do + expect(described_class).to have_graphql_fields(:feature_name, :dismissed_at) + end +end diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb index 5b3662383d8..d9e67ff348b 100644 --- a/spec/graphql/types/user_type_spec.rb +++ b/spec/graphql/types/user_type_spec.rb @@ -31,6 +31,7 @@ RSpec.describe GitlabSchema.types['User'] do groupCount projectMemberships starredProjects + callouts ] expect(described_class).to have_graphql_fields(*expected_fields) @@ -44,4 +45,12 @@ RSpec.describe GitlabSchema.types['User'] do is_expected.to have_graphql_resolver(Resolvers::Users::SnippetsResolver) end end + + describe 'callouts field' do + subject { described_class.fields['callouts'] } + + it 'returns user callouts' do + is_expected.to have_graphql_type(Types::UserCalloutType.connection_type) + end + end end diff --git a/spec/helpers/application_settings_helper_spec.rb b/spec/helpers/application_settings_helper_spec.rb index 2cd01451e0d..c74ee3ce0ec 100644 --- a/spec/helpers/application_settings_helper_spec.rb +++ b/spec/helpers/application_settings_helper_spec.rb @@ -130,20 +130,15 @@ RSpec.describe ApplicationSettingsHelper do before do helper.instance_variable_set(:@application_setting, application_setting) stub_storage_settings({ 'default': {}, 'storage_1': {}, 'storage_2': {} }) - allow(ApplicationSetting).to receive(:repository_storages_weighted_attributes).and_return( - [:repository_storages_weighted_default, - :repository_storages_weighted_storage_1, - :repository_storages_weighted_storage_2]) - stub_application_setting(repository_storages_weighted: { 'default' => 100, 'storage_1' => 50, 'storage_2' => nil }) end it 'returns storages correctly' do - expect(helper.storage_weights).to eq([ - { name: :repository_storages_weighted_default, label: 'default', value: 100 }, - { name: :repository_storages_weighted_storage_1, label: 'storage_1', value: 50 }, - { name: :repository_storages_weighted_storage_2, label: 'storage_2', value: 0 } - ]) + expect(helper.storage_weights).to eq(OpenStruct.new( + default: 100, + storage_1: 50, + storage_2: 0 + )) end end diff --git a/spec/helpers/auth_helper_spec.rb b/spec/helpers/auth_helper_spec.rb index b5d70af1336..beffa4cf60e 100644 --- a/spec/helpers/auth_helper_spec.rb +++ b/spec/helpers/auth_helper_spec.rb @@ -99,19 +99,19 @@ RSpec.describe AuthHelper do end end - describe 'experiment_enabled_button_based_providers' do + describe 'trial_enabled_button_based_providers' do it 'returns the intersection set of github & google_oauth2 with enabled providers' do allow(helper).to receive(:enabled_button_based_providers) { %w(twitter github google_oauth2) } - expect(helper.experiment_enabled_button_based_providers).to eq(%w(github google_oauth2)) + expect(helper.trial_enabled_button_based_providers).to eq(%w(github google_oauth2)) allow(helper).to receive(:enabled_button_based_providers) { %w(google_oauth2 bitbucket) } - expect(helper.experiment_enabled_button_based_providers).to eq(%w(google_oauth2)) + expect(helper.trial_enabled_button_based_providers).to eq(%w(google_oauth2)) allow(helper).to receive(:enabled_button_based_providers) { %w(bitbucket) } - expect(helper.experiment_enabled_button_based_providers).to be_empty + expect(helper.trial_enabled_button_based_providers).to be_empty end end diff --git a/spec/helpers/avatars_helper_spec.rb b/spec/helpers/avatars_helper_spec.rb index 9e18ab34c1f..7fcd5ae880a 100644 --- a/spec/helpers/avatars_helper_spec.rb +++ b/spec/helpers/avatars_helper_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe AvatarsHelper do include UploadHelpers - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } describe '#project_icon & #group_icon' do shared_examples 'resource with a default avatar' do |source_type| @@ -89,33 +89,60 @@ RSpec.describe AvatarsHelper do end end - describe '#avatar_icon_for_email' do + describe '#avatar_icon_for_email', :clean_gitlab_redis_cache do let(:user) { create(:user, avatar: File.open(uploaded_image_temp_path)) } - context 'using an email' do - context 'when there is a matching user' do - it 'returns a relative URL for the avatar' do - expect(helper.avatar_icon_for_email(user.email).to_s) - .to eq(user.avatar.url) + subject { helper.avatar_icon_for_email(user.email).to_s } + + shared_examples "returns avatar for email" do + context 'using an email' do + context 'when there is a matching user' do + it 'returns a relative URL for the avatar' do + expect(subject).to eq(user.avatar.url) + end end - end - context 'when no user exists for the email' do - it 'calls gravatar_icon' do - expect(helper).to receive(:gravatar_icon).with('foo@example.com', 20, 2) + context 'when no user exists for the email' do + it 'calls gravatar_icon' do + expect(helper).to receive(:gravatar_icon).with('foo@example.com', 20, 2) - helper.avatar_icon_for_email('foo@example.com', 20, 2) + helper.avatar_icon_for_email('foo@example.com', 20, 2) + end end - end - context 'without an email passed' do - it 'calls gravatar_icon' do - expect(helper).to receive(:gravatar_icon).with(nil, 20, 2) + context 'without an email passed' do + it 'calls gravatar_icon' do + expect(helper).to receive(:gravatar_icon).with(nil, 20, 2) + expect(User).not_to receive(:find_by_any_email) - helper.avatar_icon_for_email(nil, 20, 2) + helper.avatar_icon_for_email(nil, 20, 2) + end end end end + + context "when :avatar_cache_for_email flag is enabled" do + before do + stub_feature_flags(avatar_cache_for_email: true) + end + + it_behaves_like "returns avatar for email" + + it "caches the request" do + expect(User).to receive(:find_by_any_email).once.and_call_original + + expect(helper.avatar_icon_for_email(user.email).to_s).to eq(user.avatar.url) + expect(helper.avatar_icon_for_email(user.email).to_s).to eq(user.avatar.url) + end + end + + context "when :avatar_cache_for_email flag is disabled" do + before do + stub_feature_flags(avatar_cache_for_email: false) + end + + it_behaves_like "returns avatar for email" + end end describe '#avatar_icon_for_user' do @@ -346,7 +373,7 @@ RSpec.describe AvatarsHelper do is_expected.to eq tag( :img, alt: "#{options[:user_name]}'s avatar", - src: avatar_icon_for_email(options[:user_email], 16), + src: helper.avatar_icon_for_email(options[:user_email], 16), data: { container: 'body' }, class: "avatar s16 has-tooltip", title: options[:user_name] @@ -379,7 +406,7 @@ RSpec.describe AvatarsHelper do is_expected.to eq tag( :img, alt: "#{user_with_avatar.username}'s avatar", - src: avatar_icon_for_email(user_with_avatar.email, 16, only_path: false), + src: helper.avatar_icon_for_email(user_with_avatar.email, 16, only_path: false), data: { container: 'body' }, class: "avatar s16 has-tooltip", title: user_with_avatar.username diff --git a/spec/helpers/boards_helper_spec.rb b/spec/helpers/boards_helper_spec.rb index b85ebec5545..b00ee19cea2 100644 --- a/spec/helpers/boards_helper_spec.rb +++ b/spec/helpers/boards_helper_spec.rb @@ -3,52 +3,71 @@ require 'spec_helper' RSpec.describe BoardsHelper do - let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:base_group) { create(:group, path: 'base') } + let_it_be(:project) { create(:project, group: base_group) } + let_it_be(:project_board) { create(:board, project: project) } + let_it_be(:group_board) { create(:board, group: base_group) } describe '#build_issue_link_base' do context 'project board' do it 'returns correct path for project board' do - @project = project - @board = create(:board, project: @project) + assign(:project, project) + assign(:board, project_board) - expect(build_issue_link_base).to eq("/#{@project.namespace.path}/#{@project.path}/-/issues") + expect(helper.build_issue_link_base).to eq("/#{project.namespace.path}/#{project.path}/-/issues") end end context 'group board' do - let(:base_group) { create(:group, path: 'base') } - it 'returns correct path for base group' do - @board = create(:board, group: base_group) + assign(:board, group_board) - expect(build_issue_link_base).to eq('/base/:project_path/issues') + expect(helper.build_issue_link_base).to eq('/base/:project_path/issues') end it 'returns correct path for subgroup' do subgroup = create(:group, parent: base_group, path: 'sub') - @board = create(:board, group: subgroup) + assign(:board, create(:board, group: subgroup)) - expect(build_issue_link_base).to eq('/base/sub/:project_path/issues') + expect(helper.build_issue_link_base).to eq('/base/sub/:project_path/issues') end end end - describe '#board_data' do - let_it_be(:user) { create(:user) } - let_it_be(:board) { create(:board, project: project) } + describe '#board_base_url' do + context 'when project board' do + it 'generates the correct url' do + assign(:board, group_board) + assign(:group, base_group) + + expect(helper.board_base_url).to eq "http://test.host/groups/#{base_group.full_path}/-/boards" + end + end + + context 'when project board' do + it 'generates the correct url' do + assign(:board, project_board) + assign(:project, project) + + expect(helper.board_base_url).to eq "/#{project.full_path}/-/boards" + end + end + end + describe '#board_data' do context 'project_board' do before do assign(:project, project) - assign(:board, board) + assign(:board, project_board) allow(helper).to receive(:current_user) { user } - allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, board).and_return(true) - allow(helper).to receive(:can?).with(user, :admin_issue, board).and_return(true) + allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, project_board).and_return(true) + allow(helper).to receive(:can?).with(user, :admin_issue, project_board).and_return(true) end it 'returns a board_lists_path as lists_endpoint' do - expect(helper.board_data[:lists_endpoint]).to eq(board_lists_path(board)) + expect(helper.board_data[:lists_endpoint]).to eq(board_lists_path(project_board)) end it 'returns board type as parent' do @@ -63,28 +82,33 @@ RSpec.describe BoardsHelper do expect(helper.board_data[:labels_fetch_path]).to eq("/#{project.full_path}/-/labels.json?include_ancestor_groups=true") expect(helper.board_data[:labels_manage_path]).to eq("/#{project.full_path}/-/labels") end + + it 'returns the group id of a project' do + expect(helper.board_data[:group_id]).to eq(project.group.id) + end end context 'group board' do - let_it_be(:group) { create(:group, path: 'base') } - let_it_be(:board) { create(:board, group: group) } - before do - assign(:group, group) - assign(:board, board) + assign(:group, base_group) + assign(:board, group_board) allow(helper).to receive(:current_user) { user } - allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, board).and_return(true) - allow(helper).to receive(:can?).with(user, :admin_issue, board).and_return(true) + allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, group_board).and_return(true) + allow(helper).to receive(:can?).with(user, :admin_issue, group_board).and_return(true) end it 'returns correct path for base group' do - expect(helper.build_issue_link_base).to eq('/base/:project_path/issues') + expect(helper.build_issue_link_base).to eq("/#{base_group.full_path}/:project_path/issues") end it 'returns required label endpoints' do - expect(helper.board_data[:labels_fetch_path]).to eq("/groups/base/-/labels.json?include_ancestor_groups=true&only_group_labels=true") - expect(helper.board_data[:labels_manage_path]).to eq("/groups/base/-/labels") + expect(helper.board_data[:labels_fetch_path]).to eq("/groups/#{base_group.full_path}/-/labels.json?include_ancestor_groups=true&only_group_labels=true") + expect(helper.board_data[:labels_manage_path]).to eq("/groups/#{base_group.full_path}/-/labels") + end + + it 'returns the group id' do + expect(helper.board_data[:group_id]).to eq(base_group.id) end end end @@ -93,8 +117,7 @@ RSpec.describe BoardsHelper do let(:board_json) { helper.current_board_json } it 'can serialise with a basic set of attributes' do - board = create(:board, project: project) - assign(:board, board) + assign(:board, project_board) expect(board_json).to match_schema('current-board') end diff --git a/spec/helpers/ci/pipeline_editor_helper_spec.rb b/spec/helpers/ci/pipeline_editor_helper_spec.rb index 8f38d3b1439..7686983eb0f 100644 --- a/spec/helpers/ci/pipeline_editor_helper_spec.rb +++ b/spec/helpers/ci/pipeline_editor_helper_spec.rb @@ -19,12 +19,5 @@ RSpec.describe Ci::PipelineEditorHelper do expect(subject).to be false end - - it 'user can not view editor if feature is disabled' do - allow(helper).to receive(:can_collaborate_with_project?).and_return(true) - stub_feature_flags(ci_pipeline_editor_page: false) - - expect(subject).to be false - end end end diff --git a/spec/helpers/commits_helper_spec.rb b/spec/helpers/commits_helper_spec.rb index 2f5f4c4596b..397751b07af 100644 --- a/spec/helpers/commits_helper_spec.rb +++ b/spec/helpers/commits_helper_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe CommitsHelper do + include ProjectForksHelper + describe '#revert_commit_link' do context 'when current_user exists' do before do @@ -238,15 +240,22 @@ RSpec.describe CommitsHelper do expect(subject).to be_a(Gitlab::Git::DiffCollection) end end + end - context "feature flag is disabled" do - let(:paginate) { true } + describe '#cherry_pick_projects_data' do + let(:project) { create(:project, :repository) } + let(:user) { create(:user, maintainer_projects: [project]) } + let!(:forked_project) { fork_project(project, user, { namespace: user.namespace, repository: true }) } - it "returns a standard DiffCollection" do - stub_feature_flags(paginate_commit_view: false) + before do + allow(helper).to receive(:current_user).and_return(user) + end - expect(subject).to be_a(Gitlab::Git::DiffCollection) - end + it 'returns data for cherry picking into a project' do + expect(helper.cherry_pick_projects_data(project)).to match_array([ + { id: project.id.to_s, name: project.full_path, refsUrl: refs_project_path(project) }, + { id: forked_project.id.to_s, name: forked_project.full_path, refsUrl: refs_project_path(forked_project) } + ]) end end end diff --git a/spec/helpers/gitlab_routing_helper_spec.rb b/spec/helpers/gitlab_routing_helper_spec.rb index f23ffcee35d..0df04d2a8a7 100644 --- a/spec/helpers/gitlab_routing_helper_spec.rb +++ b/spec/helpers/gitlab_routing_helper_spec.rb @@ -332,4 +332,14 @@ RSpec.describe GitlabRoutingHelper do end end end + + context 'GraphQL ETag paths' do + context 'with pipelines' do + let(:pipeline) { double(id: 5) } + + it 'returns an ETag path for pipelines' do + expect(graphql_etag_pipeline_path(pipeline)).to eq('/api/graphql:pipelines/id/5') + end + end + end end diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb index 61aaa618c45..0d2af464902 100644 --- a/spec/helpers/groups_helper_spec.rb +++ b/spec/helpers/groups_helper_spec.rb @@ -18,11 +18,17 @@ RSpec.describe GroupsHelper do it 'gives default avatar_icon when no avatar is present' do group = create(:group) - group.save! expect(group_icon_url(group.path)).to match_asset_path('group_avatar.png') end end + describe 'group_dependency_proxy_url' do + it 'converts uppercase letters to lowercase' do + group = create(:group, path: 'GroupWithUPPERcaseLetters') + expect(group_dependency_proxy_url(group)).to end_with("/groupwithuppercaseletters#{DependencyProxy::URL_SUFFIX}") + end + end + describe 'group_lfs_status' do let(:group) { create(:group) } let!(:project) { create(:project, namespace_id: group.id) } @@ -454,18 +460,12 @@ RSpec.describe GroupsHelper do allow(helper).to receive(:current_user) { current_user } end - context 'when cached_sidebar_open_issues_count feature flag is enabled' do - before do - stub_feature_flags(cached_sidebar_open_issues_count: true) + it 'returns count value from cache' do + allow_next_instance_of(count_service) do |service| + allow(service).to receive(:count).and_return(2500) end - it 'returns count value from cache' do - allow_next_instance_of(count_service) do |service| - allow(service).to receive(:count).and_return(2500) - end - - expect(helper.group_open_issues_count(group)).to eq('2.5k') - end + expect(helper.group_open_issues_count(group)).to eq('2.5k') end context 'when cached_sidebar_open_issues_count feature flag is disabled' do diff --git a/spec/helpers/ide_helper_spec.rb b/spec/helpers/ide_helper_spec.rb new file mode 100644 index 00000000000..db30446fa95 --- /dev/null +++ b/spec/helpers/ide_helper_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe IdeHelper do + describe '#ide_data' do + let_it_be(:project) { create(:project) } + + before do + allow(helper).to receive(:current_user).and_return(project.creator) + end + + context 'when instance vars are not set' do + it 'returns instance data in the hash as nil' do + expect(helper.ide_data) + .to include( + 'branch-name' => nil, + 'file-path' => nil, + 'merge-request' => nil, + 'forked-project' => nil, + 'project' => nil + ) + end + end + + context 'when instance vars are set' do + it 'returns instance data in the hash' do + self.instance_variable_set(:@branch, 'master') + self.instance_variable_set(:@path, 'foo/bar') + self.instance_variable_set(:@merge_request, '1') + self.instance_variable_set(:@forked_project, project) + self.instance_variable_set(:@project, project) + + serialized_project = API::Entities::Project.represent(project).to_json + + expect(helper.ide_data) + .to include( + 'branch-name' => 'master', + 'file-path' => 'foo/bar', + 'merge-request' => '1', + 'forked-project' => serialized_project, + 'project' => serialized_project + ) + end + end + end +end diff --git a/spec/helpers/invite_members_helper_spec.rb b/spec/helpers/invite_members_helper_spec.rb index 576021b37b3..62bd953cce8 100644 --- a/spec/helpers/invite_members_helper_spec.rb +++ b/spec/helpers/invite_members_helper_spec.rb @@ -11,6 +11,21 @@ RSpec.describe InviteMembersHelper do helper.extend(Gitlab::Experimentation::ControllerConcern) end + describe '#show_invite_members_track_event' do + it 'shows values when can directly invite members' do + allow(helper).to receive(:directly_invite_members?).and_return(true) + + expect(helper.show_invite_members_track_event).to eq 'show_invite_members' + end + + it 'shows values when can indirectly invite members' do + allow(helper).to receive(:directly_invite_members?).and_return(false) + allow(helper).to receive(:indirectly_invite_members?).and_return(true) + + expect(helper.show_invite_members_track_event).to eq 'show_invite_members_version_b' + end + end + context 'with project' do before do assign(:project, project) @@ -56,15 +71,7 @@ RSpec.describe InviteMembersHelper do allow(helper).to receive(:current_user) { owner } end - it 'returns false' do - allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_a) { false } - - expect(helper.directly_invite_members?).to eq false - end - it 'returns true' do - allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_a) { true } - expect(helper.directly_invite_members?).to eq true end end @@ -75,8 +82,6 @@ RSpec.describe InviteMembersHelper do end it 'returns false' do - allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_a) { true } - expect(helper.directly_invite_members?).to eq false end end diff --git a/spec/helpers/issuables_description_templates_helper_spec.rb b/spec/helpers/issuables_description_templates_helper_spec.rb index 42643b755f8..e8961ccb535 100644 --- a/spec/helpers/issuables_description_templates_helper_spec.rb +++ b/spec/helpers/issuables_description_templates_helper_spec.rb @@ -13,22 +13,33 @@ RSpec.describe IssuablesDescriptionTemplatesHelper, :clean_gitlab_redis_cache do let_it_be(:group_member) { create(:group_member, :developer, group: parent_group, user: user) } let_it_be(:project_member) { create(:project_member, :developer, user: user, project: project) } - it 'returns empty hash when template type does not exist' do - expect(helper.issuable_templates(build(:project), 'non-existent-template-type')).to eq([]) + context 'when feature flag disabled' do + before do + stub_feature_flags(inherited_issuable_templates: false) + end + + it 'returns empty array when template type does not exist' do + expect(helper.issuable_templates(project, 'non-existent-template-type')).to eq([]) + end end - context 'with cached issuable templates' do + context 'when feature flag enabled' do before do - allow(Gitlab::Template::IssueTemplate).to receive(:template_names).and_return({}) - allow(Gitlab::Template::MergeRequestTemplate).to receive(:template_names).and_return({}) + stub_feature_flags(inherited_issuable_templates: true) + end - helper.issuable_templates(project, 'issues') - helper.issuable_templates(project, 'merge_request') + it 'returns empty hash when template type does not exist' do + expect(helper.issuable_templates(build(:project), 'non-existent-template-type')).to eq({}) end + end + context 'with cached issuable templates' do it 'does not call TemplateFinder' do - expect(Gitlab::Template::IssueTemplate).not_to receive(:template_names) - expect(Gitlab::Template::MergeRequestTemplate).not_to receive(:template_names) + expect(Gitlab::Template::IssueTemplate).to receive(:template_names).once.and_call_original + expect(Gitlab::Template::MergeRequestTemplate).to receive(:template_names).once.and_call_original + + helper.issuable_templates(project, 'issues') + helper.issuable_templates(project, 'merge_request') helper.issuable_templates(project, 'issues') helper.issuable_templates(project, 'merge_request') end @@ -63,29 +74,78 @@ RSpec.describe IssuablesDescriptionTemplatesHelper, :clean_gitlab_redis_cache do end describe '#issuable_templates_names' do - let(:project) { double(Project, id: 21) } - - let(:templates) do - [ - { name: "another_issue_template", id: "another_issue_template", project_id: project.id }, - { name: "custom_issue_template", id: "custom_issue_template", project_id: project.id } - ] - end + let_it_be(:project) { build(:project) } - it 'returns project templates only' do + before do allow(helper).to receive(:ref_project).and_return(project) allow(helper).to receive(:issuable_templates).and_return(templates) + end + + context 'when feature flag disabled' do + let(:templates) do + [ + { name: "another_issue_template", id: "another_issue_template", project_id: project.id }, + { name: "custom_issue_template", id: "custom_issue_template", project_id: project.id } + ] + end - expect(helper.issuable_templates_names(Issue.new)).to eq(%w[another_issue_template custom_issue_template]) + before do + stub_feature_flags(inherited_issuable_templates: false) + end + + it 'returns project templates only' do + expect(helper.issuable_templates_names(Issue.new)).to eq(%w[another_issue_template custom_issue_template]) + end + end + + context 'when feature flag enabled' do + before do + stub_feature_flags(inherited_issuable_templates: true) + end + + context 'with matching project templates' do + let(:templates) do + { + "" => [ + { name: "another_issue_template", id: "another_issue_template", project_id: project.id }, + { name: "custom_issue_template", id: "custom_issue_template", project_id: project.id } + ], + "Instance" => [ + { name: "first_issue_issue_template", id: "first_issue_issue_template", project_id: non_existing_record_id }, + { name: "second_instance_issue_template", id: "second_instance_issue_template", project_id: non_existing_record_id } + ] + } + end + + it 'returns project templates only' do + expect(helper.issuable_templates_names(Issue.new)).to eq(%w[another_issue_template custom_issue_template]) + end + end + + context 'without matching project templates' do + let(:templates) do + { + "Project Templates" => [ + { name: "another_issue_template", id: "another_issue_template", project_id: non_existing_record_id }, + { name: "custom_issue_template", id: "custom_issue_template", project_id: non_existing_record_id } + ], + "Instance" => [ + { name: "first_issue_issue_template", id: "first_issue_issue_template", project_id: non_existing_record_id }, + { name: "second_instance_issue_template", id: "second_instance_issue_template", project_id: non_existing_record_id } + ] + } + end + + it 'returns empty array' do + expect(helper.issuable_templates_names(Issue.new)).to eq([]) + end + end end context 'when there are not templates in the project' do let(:templates) { {} } it 'returns empty array' do - allow(helper).to receive(:ref_project).and_return(project) - allow(helper).to receive(:issuable_templates).and_return(templates) - expect(helper.issuable_templates_names(Issue.new)).to eq([]) end end diff --git a/spec/helpers/learn_gitlab_helper_spec.rb b/spec/helpers/learn_gitlab_helper_spec.rb index f789eb9d940..6cee8a9191c 100644 --- a/spec/helpers/learn_gitlab_helper_spec.rb +++ b/spec/helpers/learn_gitlab_helper_spec.rb @@ -41,11 +41,13 @@ RSpec.describe LearnGitlabHelper do it 'sets correct path and completion status' do expect(onboarding_actions_data[:git_write]).to eq({ url: project_issue_url(project, LearnGitlabHelper::ACTION_ISSUE_IDS[:git_write]), - completed: true + completed: true, + svg: helper.image_path("learn_gitlab/git_write.svg") }) expect(onboarding_actions_data[:pipeline_created]).to eq({ url: project_issue_url(project, LearnGitlabHelper::ACTION_ISSUE_IDS[:pipeline_created]), - completed: false + completed: false, + svg: helper.image_path("learn_gitlab/pipeline_created.svg") }) end end diff --git a/spec/helpers/merge_requests_helper_spec.rb b/spec/helpers/merge_requests_helper_spec.rb index fce4d560b2f..3cf855229bb 100644 --- a/spec/helpers/merge_requests_helper_spec.rb +++ b/spec/helpers/merge_requests_helper_spec.rb @@ -90,4 +90,57 @@ RSpec.describe MergeRequestsHelper do ) end end + + describe '#reviewers_label' do + let(:merge_request) { build_stubbed(:merge_request) } + let(:reviewer1) { build_stubbed(:user, name: 'Jane Doe') } + let(:reviewer2) { build_stubbed(:user, name: 'John Doe') } + + before do + allow(merge_request).to receive(:reviewers).and_return(reviewers) + end + + context 'when multiple reviewers exist' do + let(:reviewers) { [reviewer1, reviewer2] } + + it 'returns reviewer label with reviewer names' do + expect(helper.reviewers_label(merge_request)).to eq("Reviewers: Jane Doe and John Doe") + end + + it 'returns reviewer label only with include_value: false' do + expect(helper.reviewers_label(merge_request, include_value: false)).to eq("Reviewers") + end + + context 'when the name contains a URL' do + let(:reviewers) { [build_stubbed(:user, name: 'www.gitlab.com')] } + + it 'returns sanitized name' do + expect(helper.reviewers_label(merge_request)).to eq("Reviewer: www_gitlab_com") + end + end + end + + context 'when one reviewer exists' do + let(:reviewers) { [reviewer1] } + + it 'returns reviewer label with no names' do + expect(helper.reviewers_label(merge_request)).to eq("Reviewer: Jane Doe") + end + + it 'returns reviewer label only with include_value: false' do + expect(helper.reviewers_label(merge_request, include_value: false)).to eq("Reviewer") + end + end + + context 'when no reviewers exist' do + let(:reviewers) { [] } + + it 'returns reviewer label with no names' do + expect(helper.reviewers_label(merge_request)).to eq("Reviewers: ") + end + it 'returns reviewer label only with include_value: false' do + expect(helper.reviewers_label(merge_request, include_value: false)).to eq("Reviewers") + end + end + end end diff --git a/spec/helpers/namespaces_helper_spec.rb b/spec/helpers/namespaces_helper_spec.rb index 1636ba6ef42..b436f4ab0c9 100644 --- a/spec/helpers/namespaces_helper_spec.rb +++ b/spec/helpers/namespaces_helper_spec.rb @@ -46,13 +46,26 @@ RSpec.describe NamespacesHelper do end describe '#namespaces_options' do - it 'returns groups without being a member for admin' do - allow(helper).to receive(:current_user).and_return(admin) + context 'when admin mode is enabled', :enable_admin_mode do + it 'returns groups without being a member for admin' do + allow(helper).to receive(:current_user).and_return(admin) - options = helper.namespaces_options(user_group.id, display_path: true, extra_group: user_group.id) + options = helper.namespaces_options(user_group.id, display_path: true, extra_group: user_group.id) - expect(options).to include(admin_group.name) - expect(options).to include(user_group.name) + expect(options).to include(admin_group.name) + expect(options).to include(user_group.name) + end + end + + context 'when admin mode is disabled' do + it 'returns only allowed namespaces for admin' do + allow(helper).to receive(:current_user).and_return(admin) + + options = helper.namespaces_options(user_group.id, display_path: true, extra_group: user_group.id) + + expect(options).to include(admin_group.name) + expect(options).not_to include(user_group.name) + end end it 'returns only allowed namespaces for user' do @@ -74,13 +87,16 @@ RSpec.describe NamespacesHelper do expect(options).to include(admin_group.name) end - it 'selects existing group' do - allow(helper).to receive(:current_user).and_return(admin) + context 'when admin mode is disabled' do + it 'selects existing group' do + allow(helper).to receive(:current_user).and_return(admin) + user_group.add_owner(admin) - options = helper.namespaces_options(:extra_group, display_path: true, extra_group: user_group) + options = helper.namespaces_options(:extra_group, display_path: true, extra_group: user_group) - expect(options).to include("selected=\"selected\" value=\"#{user_group.id}\"") - expect(options).to include(admin_group.name) + expect(options).to include("selected=\"selected\" value=\"#{user_group.id}\"") + expect(options).to include(admin_group.name) + end end it 'selects the new group by default' do diff --git a/spec/helpers/notifications_helper_spec.rb b/spec/helpers/notifications_helper_spec.rb index 555cffba614..a5338659659 100644 --- a/spec/helpers/notifications_helper_spec.rb +++ b/spec/helpers/notifications_helper_spec.rb @@ -19,22 +19,6 @@ RSpec.describe NotificationsHelper do it { expect(notification_title(:global)).to match('Global') } end - describe '#notification_event_name' do - context 'for success_pipeline' do - it 'returns the custom name' do - expect(FastGettext).to receive(:cached_find).with('NotificationEvent|Successful pipeline') - expect(notification_event_name(:success_pipeline)).to eq('Successful pipeline') - end - end - - context 'for everything else' do - it 'returns a humanized name' do - expect(FastGettext).to receive(:cached_find).with('NotificationEvent|Failed pipeline') - expect(notification_event_name(:failed_pipeline)).to eq('Failed pipeline') - end - end - end - describe '#notification_icon_level' do let(:user) { create(:user) } let(:global_setting) { user.global_notification_setting } diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb index be0ad5e1a3f..e5420fb6729 100644 --- a/spec/helpers/preferences_helper_spec.rb +++ b/spec/helpers/preferences_helper_spec.rb @@ -29,6 +29,7 @@ RSpec.describe PreferencesHelper do ['Starred Projects', 'stars'], ["Your Projects' Activity", 'project_activity'], ["Starred Projects' Activity", 'starred_project_activity'], + ["Followed Users' Activity", 'followed_user_activity'], ["Your Groups", 'groups'], ["Your To-Do List", 'todos'], ["Assigned Issues", 'issues'], diff --git a/spec/helpers/projects/project_members_helper_spec.rb b/spec/helpers/projects/project_members_helper_spec.rb index 5e0b4df7f7f..1a55840a58a 100644 --- a/spec/helpers/projects/project_members_helper_spec.rb +++ b/spec/helpers/projects/project_members_helper_spec.rb @@ -166,7 +166,7 @@ RSpec.describe Projects::ProjectMembersHelper do members: helper.project_members_data_json(project, present_members(project_members)), member_path: '/foo-bar/-/project_members/:id', source_id: project.id, - can_manage_members: true + can_manage_members: 'true' }) end end @@ -193,7 +193,7 @@ RSpec.describe Projects::ProjectMembersHelper do members: helper.project_group_links_data_json(project_group_links), member_path: '/foo-bar/-/group_links/:id', source_id: project.id, - can_manage_members: true + can_manage_members: 'true' }) end end diff --git a/spec/helpers/projects/security/configuration_helper_spec.rb b/spec/helpers/projects/security/configuration_helper_spec.rb new file mode 100644 index 00000000000..c5049bd87f0 --- /dev/null +++ b/spec/helpers/projects/security/configuration_helper_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::Security::ConfigurationHelper do + let(:current_user) { create(:user) } + + describe 'security_upgrade_path' do + subject { security_upgrade_path } + + it { is_expected.to eq('https://about.gitlab.com/pricing/') } + end +end diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 303e3c78153..e6cd11a4d70 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -401,40 +401,20 @@ RSpec.describe ProjectsHelper do context 'Security & Compliance tabs' do before do - stub_feature_flags(secure_security_and_compliance_configuration_page_on_ce: feature_flag_enabled) allow(helper).to receive(:can?).with(user, :read_security_configuration, project).and_return(can_read_security_configuration) end context 'when user cannot read security configuration' do let(:can_read_security_configuration) { false } - context 'when feature flag is disabled' do - let(:feature_flag_enabled) { false } - - it { is_expected.not_to include(:security_configuration) } - end - - context 'when feature flag is enabled' do - let(:feature_flag_enabled) { true } - - it { is_expected.not_to include(:security_configuration) } - end + it { is_expected.not_to include(:security_configuration) } end context 'when user can read security configuration' do let(:can_read_security_configuration) { true } + let(:feature_flag_enabled) { true } - context 'when feature flag is disabled' do - let(:feature_flag_enabled) { false } - - it { is_expected.not_to include(:security_configuration) } - end - - context 'when feature flag is enabled' do - let(:feature_flag_enabled) { true } - - it { is_expected.to include(:security_configuration) } - end + it { is_expected.to include(:security_configuration) } end end diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb index a977f2c88c6..13d3a80bd13 100644 --- a/spec/helpers/search_helper_spec.rb +++ b/spec/helpers/search_helper_spec.rb @@ -534,10 +534,11 @@ RSpec.describe SearchHelper do where(:description, :expected) do 'test' | '<span class="gl-text-gray-900 gl-font-weight-bold">test</span>' - '<span style="color: blue;">this test should not be blue</span>' | '<span>this <span class="gl-text-gray-900 gl-font-weight-bold">test</span> should not be blue</span>' + '<span style="color: blue;">this test should not be blue</span>' | 'this <span class="gl-text-gray-900 gl-font-weight-bold">test</span> should not be blue' '<a href="#" onclick="alert(\'XSS\')">Click Me test</a>' | '<a href="#">Click Me <span class="gl-text-gray-900 gl-font-weight-bold">test</span></a>' '<script type="text/javascript">alert(\'Another XSS\');</script> test' | ' <span class="gl-text-gray-900 gl-font-weight-bold">test</span>' 'Lorem test ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec.' | 'Lorem <span class="gl-text-gray-900 gl-font-weight-bold">test</span> ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Don...' + '<img src="https://random.foo.com/test.png" width="128" height="128" />some image' | 'some image' end with_them do @@ -580,7 +581,7 @@ RSpec.describe SearchHelper do describe '#issuable_state_to_badge_class' do context 'with merge request' do it 'returns correct badge based on status' do - expect(issuable_state_to_badge_class(build(:merge_request, :merged))).to eq(:primary) + expect(issuable_state_to_badge_class(build(:merge_request, :merged))).to eq(:info) expect(issuable_state_to_badge_class(build(:merge_request, :closed))).to eq(:danger) expect(issuable_state_to_badge_class(build(:merge_request, :opened))).to eq(:success) end diff --git a/spec/helpers/services_helper_spec.rb b/spec/helpers/services_helper_spec.rb index 534f33d9b5a..1726a8362a7 100644 --- a/spec/helpers/services_helper_spec.rb +++ b/spec/helpers/services_helper_spec.rb @@ -4,27 +4,35 @@ require 'spec_helper' RSpec.describe ServicesHelper do describe '#integration_form_data' do + let(:fields) do + [ + :id, + :show_active, + :activated, + :type, + :merge_request_events, + :commit_events, + :enable_comments, + :comment_detail, + :learn_more_path, + :trigger_events, + :fields, + :inherit_from_id, + :integration_level, + :editable, + :cancel_path, + :can_test, + :test_path, + :reset_path + ] + end + subject { helper.integration_form_data(integration) } - context 'Jira service' do - let(:integration) { build(:jira_service) } - - it 'includes Jira specific fields' do - is_expected.to include( - :id, - :show_active, - :activated, - :type, - :merge_request_events, - :commit_events, - :enable_comments, - :comment_detail, - :trigger_events, - :fields, - :inherit_from_id, - :integration_level - ) - end + context 'Slack service' do + let(:integration) { build(:slack_service) } + + it { is_expected.to include(*fields) } specify do expect(subject[:reset_path]).to eq(helper.scoped_reset_integration_path(integration)) diff --git a/spec/helpers/stat_anchors_helper_spec.rb b/spec/helpers/stat_anchors_helper_spec.rb index 0615baac3cb..f3830bf4172 100644 --- a/spec/helpers/stat_anchors_helper_spec.rb +++ b/spec/helpers/stat_anchors_helper_spec.rb @@ -18,7 +18,7 @@ RSpec.describe StatAnchorsHelper do context 'when anchor is not a link' do context 'when class_modifier is set' do - let(:anchor) { anchor_klass.new(false, nil, nil, 'default') } + let(:anchor) { anchor_klass.new(false, nil, nil, 'btn-default') } it 'returns the proper attributes' do expect(subject[:class]).to include('gl-button btn btn-default') @@ -49,5 +49,21 @@ RSpec.describe StatAnchorsHelper do expect(subject[:itemprop]).to eq true end end + + context 'when data is not set' do + let(:anchor) { anchor_klass.new(false, nil, nil, nil, nil, nil, nil) } + + it 'returns the data attributes' do + expect(subject[:data]).to be_nil + end + end + + context 'when itemprop is set' do + let(:anchor) { anchor_klass.new(false, nil, nil, nil, nil, nil, { 'toggle' => 'modal' }) } + + it 'returns the data attributes' do + expect(subject[:data]).to eq({ 'toggle' => 'modal' }) + end + end end end diff --git a/spec/helpers/timeboxes_helper_spec.rb b/spec/helpers/timeboxes_helper_spec.rb index 94e997f7a65..9cbed7668ac 100644 --- a/spec/helpers/timeboxes_helper_spec.rb +++ b/spec/helpers/timeboxes_helper_spec.rb @@ -58,15 +58,6 @@ RSpec.describe TimeboxesHelper do it { expect(result_for(start_date: yesterday)).to eq("started on #{yesterday_formatted}") } it { expect(result_for(start_date: yesterday, due_date: tomorrow)).to eq("#{yesterday_formatted}–#{tomorrow_formatted}") } end - - context 'iteration' do - # Iterations always have start and due dates, so only A-B format is expected - it 'formats properly' do - iteration = build(:iteration, start_date: yesterday, due_date: tomorrow) - - expect(timebox_date_range(iteration)).to eq("#{yesterday_formatted}–#{tomorrow_formatted}") - end - end end describe '#milestone_counts' do diff --git a/spec/helpers/visibility_level_helper_spec.rb b/spec/helpers/visibility_level_helper_spec.rb index 10e0815918f..2aac0cae0c6 100644 --- a/spec/helpers/visibility_level_helper_spec.rb +++ b/spec/helpers/visibility_level_helper_spec.rb @@ -33,6 +33,22 @@ RSpec.describe VisibilityLevelHelper do end end + describe 'visibility_level_label' do + using RSpec::Parameterized::TableSyntax + + where(:level_value, :level_name) do + Gitlab::VisibilityLevel::PRIVATE | 'Private' + Gitlab::VisibilityLevel::INTERNAL | 'Internal' + Gitlab::VisibilityLevel::PUBLIC | 'Public' + end + + with_them do + it 'returns the name of the visibility level' do + expect(visibility_level_label(level_value)).to eq(level_name) + end + end + end + describe 'visibility_level_description' do context 'used with a Project' do let(:descriptions) do diff --git a/spec/initializers/rack_multipart_patch_spec.rb b/spec/initializers/rack_multipart_patch_spec.rb new file mode 100644 index 00000000000..862fdc7901b --- /dev/null +++ b/spec/initializers/rack_multipart_patch_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Rack::Multipart do # rubocop:disable RSpec/FilePath + def multipart_fixture(name, length, boundary = "AaB03x") + data = <<EOF +--#{boundary}\r +content-disposition: form-data; name="reply"\r +\r +yes\r +--#{boundary}\r +content-disposition: form-data; name="fileupload"; filename="dj.jpg"\r +Content-Type: image/jpeg\r +Content-Transfer-Encoding: base64\r +\r +/9j/4AAQSkZJRgABAQAAAQABAAD//gA+Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcg\r +--#{boundary}--\r +EOF + + type = %(multipart/form-data; boundary=#{boundary}) + + length ||= data.bytesize + + { + "CONTENT_TYPE" => type, + "CONTENT_LENGTH" => length.to_s, + input: StringIO.new(data) + } + end + + context 'with Content-Length under the limit' do + it 'extracts multipart message' do + env = Rack::MockRequest.env_for("/", multipart_fixture(:text, nil)) + + expect(described_class).to receive(:log_large_multipart?).and_call_original + expect(described_class).not_to receive(:log_multipart_warning) + params = described_class.parse_multipart(env) + + expect(params.keys).to include(*%w(reply fileupload)) + end + end + + context 'with Content-Length over the limit' do + shared_examples 'logs multipart message' do + it 'extracts multipart message' do + env = Rack::MockRequest.env_for("/", multipart_fixture(:text, length)) + + expect(described_class).to receive(:log_large_multipart?).and_return(true) + expect(described_class).to receive(:log_multipart_warning).and_call_original + expect(described_class).to receive(:log_warn).with({ + message: 'Large multipart body detected', + path: '/', + content_length: anything, + correlation_id: anything + }) + params = described_class.parse_multipart(env) + + expect(params.keys).to include(*%w(reply fileupload)) + end + end + + context 'from environment' do + let(:length) { 1001 } + + before do + stub_env('RACK_MULTIPART_LOGGING_BYTES', 1000) + end + + it_behaves_like 'logs multipart message' + end + + context 'default limit' do + let(:length) { 100_000_001 } + + it_behaves_like 'logs multipart message' + end + end +end diff --git a/spec/lib/api/entities/plan_limit_spec.rb b/spec/lib/api/entities/plan_limit_spec.rb new file mode 100644 index 00000000000..ee42c67f9b6 --- /dev/null +++ b/spec/lib/api/entities/plan_limit_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Entities::PlanLimit do + let(:plan_limits) { create(:plan_limits) } + + subject { described_class.new(plan_limits).as_json } + + it 'exposes correct attributes' do + expect(subject).to include( + :conan_max_file_size, + :generic_packages_max_file_size, + :maven_max_file_size, + :npm_max_file_size, + :nuget_max_file_size, + :pypi_max_file_size + ) + end + + it 'does not expose id and plan_id' do + expect(subject).not_to include(:id, :plan_id) + end +end diff --git a/spec/lib/api/entities/project_repository_storage_move_spec.rb b/spec/lib/api/entities/projects/repository_storage_move_spec.rb index b0102dc376a..81f5d98b713 100644 --- a/spec/lib/api/entities/project_repository_storage_move_spec.rb +++ b/spec/lib/api/entities/projects/repository_storage_move_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe API::Entities::ProjectRepositoryStorageMove do +RSpec.describe API::Entities::Projects::RepositoryStorageMove do describe '#as_json' do subject { entity.as_json } diff --git a/spec/lib/api/entities/public_group_details_spec.rb b/spec/lib/api/entities/public_group_details_spec.rb new file mode 100644 index 00000000000..34162ed00ca --- /dev/null +++ b/spec/lib/api/entities/public_group_details_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Entities::PublicGroupDetails do + subject(:entity) { described_class.new(group) } + + let(:group) { create(:group, :with_avatar) } + + describe '#as_json' do + subject { entity.as_json } + + it 'includes public group fields' do + is_expected.to eq( + id: group.id, + name: group.name, + web_url: group.web_url, + avatar_url: group.avatar_url(only_path: false), + full_name: group.full_name, + full_path: group.full_path + ) + end + end +end diff --git a/spec/lib/api/entities/snippet_repository_storage_move_spec.rb b/spec/lib/api/entities/snippets/repository_storage_move_spec.rb index 8086be3ffa7..a848afbcff9 100644 --- a/spec/lib/api/entities/snippet_repository_storage_move_spec.rb +++ b/spec/lib/api/entities/snippets/repository_storage_move_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe API::Entities::SnippetRepositoryStorageMove do +RSpec.describe API::Entities::Snippets::RepositoryStorageMove do describe '#as_json' do subject { entity.as_json } diff --git a/spec/lib/backup/repositories_spec.rb b/spec/lib/backup/repositories_spec.rb index 492058c6a00..7a8cc713e4f 100644 --- a/spec/lib/backup/repositories_spec.rb +++ b/spec/lib/backup/repositories_spec.rb @@ -230,6 +230,16 @@ RSpec.describe Backup::Repositories do expect(pool_repository).not_to be_failed expect(pool_repository.object_pool.exists?).to be(true) end + + it 'skips pools with no source project, :sidekiq_might_not_need_inline' do + pool_repository = create(:pool_repository, state: :obsolete) + pool_repository.update_column(:source_project_id, nil) + + subject.restore + + pool_repository.reload + expect(pool_repository).to be_obsolete + end end it 'cleans existing repositories' do diff --git a/spec/lib/banzai/filter/custom_emoji_filter_spec.rb b/spec/lib/banzai/filter/custom_emoji_filter_spec.rb index ca8c9750e7f..5e76e8164dd 100644 --- a/spec/lib/banzai/filter/custom_emoji_filter_spec.rb +++ b/spec/lib/banzai/filter/custom_emoji_filter_spec.rb @@ -10,6 +10,10 @@ RSpec.describe Banzai::Filter::CustomEmojiFilter do let_it_be(:custom_emoji) { create(:custom_emoji, name: 'tanuki', group: group) } let_it_be(:custom_emoji2) { create(:custom_emoji, name: 'happy_tanuki', group: group, file: 'https://foo.bar/happy.png') } + it_behaves_like 'emoji filter' do + let(:emoji_name) { ':tanuki:' } + end + it 'replaces supported name custom emoji' do doc = filter('<p>:tanuki:</p>', project: project) @@ -17,25 +21,12 @@ RSpec.describe Banzai::Filter::CustomEmojiFilter do expect(doc.css('gl-emoji img').size).to eq 1 end - it 'ignores non existent custom emoji' do - exp = act = '<p>:foo:</p>' - doc = filter(act) - - expect(doc.to_html).to match Regexp.escape(exp) - end - it 'correctly uses the custom emoji URL' do doc = filter('<p>:tanuki:</p>') expect(doc.css('img').first.attributes['src'].value).to eq(custom_emoji.file) end - it 'matches with adjacent text' do - doc = filter('tanuki (:tanuki:)') - - expect(doc.css('img').size).to eq 1 - end - it 'matches multiple same custom emoji' do doc = filter(':tanuki: :tanuki:') @@ -54,18 +45,6 @@ RSpec.describe Banzai::Filter::CustomEmojiFilter do expect(doc.css('img').size).to be 0 end - it 'keeps whitespace intact' do - doc = filter('This deserves a :tanuki:, big time.') - - expect(doc.to_html).to match(/^This deserves a <gl-emoji.+>, big time\.\z/) - end - - it 'does not match emoji in a string' do - doc = filter("'2a00:tanuki:100::1'") - - expect(doc.css('gl-emoji').size).to eq 0 - end - it 'does not do N+1 query' do create(:custom_emoji, name: 'party-parrot', group: group) diff --git a/spec/lib/banzai/filter/emoji_filter_spec.rb b/spec/lib/banzai/filter/emoji_filter_spec.rb index 9005b4401b7..cb0b470eaa1 100644 --- a/spec/lib/banzai/filter/emoji_filter_spec.rb +++ b/spec/lib/banzai/filter/emoji_filter_spec.rb @@ -5,6 +5,10 @@ require 'spec_helper' RSpec.describe Banzai::Filter::EmojiFilter do include FilterSpecHelper + it_behaves_like 'emoji filter' do + let(:emoji_name) { ':+1:' } + end + it 'replaces supported name emoji' do doc = filter('<p>:heart:</p>') expect(doc.css('gl-emoji').first.text).to eq '❤' @@ -15,12 +19,6 @@ RSpec.describe Banzai::Filter::EmojiFilter do expect(doc.css('gl-emoji').first.text).to eq '❤' end - it 'ignores unsupported emoji' do - exp = act = '<p>:foo:</p>' - doc = filter(act) - expect(doc.to_html).to match Regexp.escape(exp) - end - it 'ignores unicode versions of trademark, copyright, and registered trademark' do exp = act = '<p>™ © ®</p>' doc = filter(act) @@ -65,11 +63,6 @@ RSpec.describe Banzai::Filter::EmojiFilter do expect(doc.css('gl-emoji').size).to eq 1 end - it 'matches with adjacent text' do - doc = filter('+1 (:+1:)') - expect(doc.css('gl-emoji').size).to eq 1 - end - it 'unicode matches with adjacent text' do doc = filter('+1 (👍)') expect(doc.css('gl-emoji').size).to eq 1 @@ -90,12 +83,6 @@ RSpec.describe Banzai::Filter::EmojiFilter do expect(doc.css('gl-emoji').size).to eq 6 end - it 'does not match emoji in a string' do - doc = filter("'2a00:a4c0:100::1'") - - expect(doc.css('gl-emoji').size).to eq 0 - end - it 'has a data-name attribute' do doc = filter(':-1:') expect(doc.css('gl-emoji').first.attr('data-name')).to eq 'thumbsdown' @@ -106,12 +93,6 @@ RSpec.describe Banzai::Filter::EmojiFilter do expect(doc.css('gl-emoji').first.attr('data-unicode-version')).to eq '6.0' end - it 'keeps whitespace intact' do - doc = filter('This deserves a :+1:, big time.') - - expect(doc.to_html).to match(/^This deserves a <gl-emoji.+>, big time\.\z/) - end - it 'unicode keeps whitespace intact' do doc = filter('This deserves a 🎱, big time.') diff --git a/spec/lib/banzai/filter/gollum_tags_filter_spec.rb b/spec/lib/banzai/filter/gollum_tags_filter_spec.rb index f39b5280490..ec17bb26346 100644 --- a/spec/lib/banzai/filter/gollum_tags_filter_spec.rb +++ b/spec/lib/banzai/filter/gollum_tags_filter_spec.rb @@ -22,7 +22,7 @@ RSpec.describe Banzai::Filter::GollumTagsFilter do path: 'images/image.jpg', raw_data: '') wiki_file = Gitlab::Git::WikiFile.new(gollum_file_double) - expect(wiki).to receive(:find_file).with('images/image.jpg').and_return(wiki_file) + expect(wiki).to receive(:find_file).with('images/image.jpg', load_content: false).and_return(wiki_file) tag = '[[images/image.jpg]]' doc = filter("See #{tag}", wiki: wiki) @@ -31,7 +31,7 @@ RSpec.describe Banzai::Filter::GollumTagsFilter do end it 'does not creates img tag if image does not exist' do - expect(wiki).to receive(:find_file).with('images/image.jpg').and_return(nil) + expect(wiki).to receive(:find_file).with('images/image.jpg', load_content: false).and_return(nil) tag = '[[images/image.jpg]]' doc = filter("See #{tag}", wiki: wiki) diff --git a/spec/lib/banzai/filter/sanitization_filter_spec.rb b/spec/lib/banzai/filter/sanitization_filter_spec.rb index bc4b60dfe60..f880fe06ce3 100644 --- a/spec/lib/banzai/filter/sanitization_filter_spec.rb +++ b/spec/lib/banzai/filter/sanitization_filter_spec.rb @@ -33,14 +33,14 @@ RSpec.describe Banzai::Filter::SanitizationFilter do end it 'sanitizes `class` attribute from all elements' do - act = %q{<pre class="code highlight white c"><code><span class="k">def</span></code></pre>} - exp = %q{<pre><code><span class="k">def</span></code></pre>} + act = %q(<pre class="code highlight white c"><code><span class="k">def</span></code></pre>) + exp = %q(<pre><code><span class="k">def</span></code></pre>) expect(filter(act).to_html).to eq exp end it 'sanitizes `class` attribute from non-highlight spans' do - act = %q{<span class="k">def</span>} - expect(filter(act).to_html).to eq %q{<span>def</span>} + act = %q(<span class="k">def</span>) + expect(filter(act).to_html).to eq %q(<span>def</span>) end it 'allows `text-align` property in `style` attribute on table elements' do @@ -82,12 +82,12 @@ RSpec.describe Banzai::Filter::SanitizationFilter do end it 'allows `span` elements' do - exp = act = %q{<span>Hello</span>} + exp = act = %q(<span>Hello</span>) expect(filter(act).to_html).to eq exp end it 'allows `abbr` elements' do - exp = act = %q{<abbr title="HyperText Markup Language">HTML</abbr>} + exp = act = %q(<abbr title="HyperText Markup Language">HTML</abbr>) expect(filter(act).to_html).to eq exp end @@ -132,7 +132,7 @@ RSpec.describe Banzai::Filter::SanitizationFilter do end it 'allows the `data-sourcepos` attribute globally' do - exp = %q{<p data-sourcepos="1:1-1:10">foo/bar.md</p>} + exp = %q(<p data-sourcepos="1:1-1:10">foo/bar.md</p>) act = filter(exp) expect(act.to_html).to eq exp @@ -140,41 +140,41 @@ RSpec.describe Banzai::Filter::SanitizationFilter do describe 'footnotes' do it 'allows correct footnote id property on links' do - exp = %q{<a href="#fn1" id="fnref1">foo/bar.md</a>} + exp = %q(<a href="#fn1" id="fnref1">foo/bar.md</a>) act = filter(exp) expect(act.to_html).to eq exp end it 'allows correct footnote id property on li element' do - exp = %q{<ol><li id="fn1">footnote</li></ol>} + exp = %q(<ol><li id="fn1">footnote</li></ol>) act = filter(exp) expect(act.to_html).to eq exp end it 'removes invalid id for footnote links' do - exp = %q{<a href="#fn1">link</a>} + exp = %q(<a href="#fn1">link</a>) %w[fnrefx test xfnref1].each do |id| - act = filter(%Q{<a href="#fn1" id="#{id}">link</a>}) + act = filter(%(<a href="#fn1" id="#{id}">link</a>)) expect(act.to_html).to eq exp end end it 'removes invalid id for footnote li' do - exp = %q{<ol><li>footnote</li></ol>} + exp = %q(<ol><li>footnote</li></ol>) %w[fnx test xfn1].each do |id| - act = filter(%Q{<ol><li id="#{id}">footnote</li></ol>}) + act = filter(%(<ol><li id="#{id}">footnote</li></ol>)) expect(act.to_html).to eq exp end end it 'allows footnotes numbered higher than 9' do - exp = %q{<a href="#fn15" id="fnref15">link</a><ol><li id="fn15">footnote</li></ol>} + exp = %q(<a href="#fn15" id="fnref15">link</a><ol><li id="fn15">footnote</li></ol>) act = filter(exp) expect(act.to_html).to eq exp diff --git a/spec/lib/banzai/filter/video_link_filter_spec.rb b/spec/lib/banzai/filter/video_link_filter_spec.rb index 32fbc6b687f..ec954aa9163 100644 --- a/spec/lib/banzai/filter/video_link_filter_spec.rb +++ b/spec/lib/banzai/filter/video_link_filter_spec.rb @@ -33,6 +33,7 @@ RSpec.describe Banzai::Filter::VideoLinkFilter do expect(video.name).to eq 'video' expect(video['src']).to eq src expect(video['width']).to eq "400" + expect(video['preload']).to eq 'metadata' expect(paragraph.name).to eq 'p' diff --git a/spec/lib/banzai/pipeline/full_pipeline_spec.rb b/spec/lib/banzai/pipeline/full_pipeline_spec.rb index bcee6f8f65d..989e06a992d 100644 --- a/spec/lib/banzai/pipeline/full_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/full_pipeline_spec.rb @@ -142,5 +142,12 @@ RSpec.describe Banzai::Pipeline::FullPipeline do expect(output).to include("<span>#</span>#{issue.iid}") end + + it 'converts user reference with escaped underscore because of italics' do + markdown = '_@test\__' + output = described_class.to_html(markdown, project: project) + + expect(output).to include('<em>@test_</em>') + end end end diff --git a/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb b/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb index 241d6db4f11..5f31ad0c8f6 100644 --- a/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb @@ -31,11 +31,13 @@ RSpec.describe Banzai::Pipeline::PlainMarkdownPipeline do end end - # Test strings taken from https://spec.commonmark.org/0.29/#backslash-escapes describe 'CommonMark tests', :aggregate_failures do - it 'converts all ASCII punctuation to literals' do - markdown = %q(\!\"\#\$\%\&\'\*\+\,\-\.\/\:\;\<\=\>\?\@\[\]\^\_\`\{\|\}\~) + %q[\(\)\\\\] - punctuation = %w(! " # $ % & ' * + , - . / : ; < = > ? @ [ \\ ] ^ _ ` { | } ~) + %w[( )] + it 'converts all reference punctuation to literals' do + reference_chars = Banzai::Filter::MarkdownPreEscapeFilter::REFERENCE_CHARACTERS + markdown = reference_chars.split('').map {|char| char.prepend("\\") }.join + punctuation = Banzai::Filter::MarkdownPreEscapeFilter::REFERENCE_CHARACTERS.split('') + punctuation = punctuation.delete_if {|char| char == '&' } + punctuation << '&' result = described_class.call(markdown, project: project) output = result[:output].to_html @@ -44,57 +46,45 @@ RSpec.describe Banzai::Pipeline::PlainMarkdownPipeline do expect(result[:escaped_literals]).to be_truthy end - it 'does not convert other characters to literals' do - markdown = %q(\→\A\a\ \3\φ\«) - expected = '\→\A\a\ \3\φ\«' - - result = correct_html_included(markdown, expected) - expect(result[:escaped_literals]).to be_falsey - end + it 'ensure we handle all the GitLab reference characters' do + reference_chars = ObjectSpace.each_object(Class).map do |klass| + next unless klass.included_modules.include?(Referable) + next unless klass.respond_to?(:reference_prefix) + next unless klass.reference_prefix.length == 1 - describe 'escaped characters are treated as regular characters and do not have their usual Markdown meanings' do - where(:markdown, :expected) do - %q(\*not emphasized*) | %q(<span>*</span>not emphasized*) - %q(\<br/> not a tag) | %q(<span><</span>br/> not a tag) - %q!\[not a link](/foo)! | %q!<span>[</span>not a link](/foo)! - %q(\`not code`) | %q(<span>`</span>not code`) - %q(1\. not a list) | %q(1<span>.</span> not a list) - %q(\# not a heading) | %q(<span>#</span> not a heading) - %q(\[foo]: /url "not a reference") | %q(<span>[</span>foo]: /url "not a reference") - %q(\ö not a character entity) | %q(<span>&</span>ouml; not a character entity) - end + klass.reference_prefix + end.compact - with_them do - it 'keeps them as literals' do - correct_html_included(markdown, expected) - end + reference_chars.all? do |char| + Banzai::Filter::MarkdownPreEscapeFilter::REFERENCE_CHARACTERS.include?(char) end end - it 'backslash is itself escaped, the following character is not' do - markdown = %q(\\\\*emphasis*) - expected = %q(<span>\</span><em>emphasis</em>) + it 'does not convert non-reference punctuation to spans' do + markdown = %q(\"\'\*\+\,\-\.\/\:\;\<\=\>\?\[\]\_\`\{\|\}) + %q[\(\)\\\\] - correct_html_included(markdown, expected) + result = described_class.call(markdown, project: project) + output = result[:output].to_html + + expect(output).not_to include('<span>') + expect(result[:escaped_literals]).to be_falsey end - it 'backslash at the end of the line is a hard line break' do - markdown = <<~MARKDOWN - foo\\ - bar - MARKDOWN - expected = "foo<br>\nbar" + it 'does not convert other characters to literals' do + markdown = %q(\→\A\a\ \3\φ\«) + expected = '\→\A\a\ \3\φ\«' - correct_html_included(markdown, expected) + result = correct_html_included(markdown, expected) + expect(result[:escaped_literals]).to be_falsey end describe 'backslash escapes do not work in code blocks, code spans, autolinks, or raw HTML' do where(:markdown, :expected) do - %q(`` \[\` ``) | %q(<code>\[\`</code>) - %q( \[\]) | %Q(<code>\\[\\]\n</code>) - %Q(~~~\n\\[\\]\n~~~) | %Q(<code>\\[\\]\n</code>) - %q(<http://example.com?find=\*>) | %q(<a href="http://example.com?find=%5C*">http://example.com?find=\*</a>) - %q[<a href="/bar\/)">] | %q[<a href="/bar%5C/)">] + %q(`` \@\! ``) | %q(<code>\@\!</code>) + %q( \@\!) | %Q(<code>\\@\\!\n</code>) + %Q(~~~\n\\@\\!\n~~~) | %Q(<code>\\@\\!\n</code>) + %q(<http://example.com?find=\@>) | %q(<a href="http://example.com?find=%5C@">http://example.com?find=\@</a>) + %q[<a href="/bar\@)">] | %q[<a href="/bar%5C@)">] end with_them do @@ -104,9 +94,9 @@ RSpec.describe Banzai::Pipeline::PlainMarkdownPipeline do describe 'work in all other contexts, including URLs and link titles, link references, and info strings in fenced code blocks' do where(:markdown, :expected) do - %q![foo](/bar\* "ti\*tle")! | %q(<a href="/bar*" title="ti*tle">foo</a>) - %Q![foo]\n\n[foo]: /bar\\* "ti\\*tle"! | %q(<a href="/bar*" title="ti*tle">foo</a>) - %Q(``` foo\\+bar\nfoo\n```) | %Q(<code lang="foo+bar">foo\n</code>) + %q![foo](/bar\@ "\@title")! | %q(<a href="/bar@" title="@title">foo</a>) + %Q![foo]\n\n[foo]: /bar\\@ "\\@title"! | %q(<a href="/bar@" title="@title">foo</a>) + %Q(``` foo\\@bar\nfoo\n```) | %Q(<code lang="foo@bar">foo\n</code>) end with_them do diff --git a/spec/lib/bulk_imports/common/loaders/entity_loader_spec.rb b/spec/lib/bulk_imports/common/loaders/entity_loader_spec.rb deleted file mode 100644 index 57ffdfa9aee..00000000000 --- a/spec/lib/bulk_imports/common/loaders/entity_loader_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe BulkImports::Common::Loaders::EntityLoader do - describe '#load' do - it "creates entities for the given data" do - group = create(:group, path: "imported-group") - parent_entity = create(:bulk_import_entity, group: group, bulk_import: create(:bulk_import)) - context = BulkImports::Pipeline::Context.new(parent_entity) - - data = { - source_type: :group_entity, - source_full_path: "parent/subgroup", - destination_name: "subgroup", - destination_namespace: parent_entity.group.full_path, - parent_id: parent_entity.id - } - - expect { subject.load(context, data) }.to change(BulkImports::Entity, :count).by(1) - - subgroup_entity = BulkImports::Entity.last - - expect(subgroup_entity.source_full_path).to eq 'parent/subgroup' - expect(subgroup_entity.destination_namespace).to eq 'imported-group' - expect(subgroup_entity.destination_name).to eq 'subgroup' - expect(subgroup_entity.parent_id).to eq parent_entity.id - end - end -end diff --git a/spec/lib/bulk_imports/common/transformers/prohibited_attributes_transformer_spec.rb b/spec/lib/bulk_imports/common/transformers/prohibited_attributes_transformer_spec.rb index 03d138b227c..08a82bc84ed 100644 --- a/spec/lib/bulk_imports/common/transformers/prohibited_attributes_transformer_spec.rb +++ b/spec/lib/bulk_imports/common/transformers/prohibited_attributes_transformer_spec.rb @@ -68,5 +68,11 @@ RSpec.describe BulkImports::Common::Transformers::ProhibitedAttributesTransforme expect(transformed_hash).to eq(expected_hash) end + + context 'when there is no data to transform' do + it 'returns' do + expect(subject.transform(nil, nil)).to be_nil + end + end end end diff --git a/spec/lib/bulk_imports/common/transformers/award_emoji_transformer_spec.rb b/spec/lib/bulk_imports/common/transformers/user_reference_transformer_spec.rb index 5b560a30bf5..ff11a10bfe9 100644 --- a/spec/lib/bulk_imports/common/transformers/award_emoji_transformer_spec.rb +++ b/spec/lib/bulk_imports/common/transformers/user_reference_transformer_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe BulkImports::Common::Transformers::AwardEmojiTransformer do +RSpec.describe BulkImports::Common::Transformers::UserReferenceTransformer do describe '#transform' do let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group) } @@ -12,7 +12,6 @@ RSpec.describe BulkImports::Common::Transformers::AwardEmojiTransformer do let(:hash) do { - 'name' => 'thumbs up', 'user' => { 'public_email' => email } @@ -44,5 +43,27 @@ RSpec.describe BulkImports::Common::Transformers::AwardEmojiTransformer do include_examples 'sets user_id and removes user key' end + + context 'when there is no data to transform' do + it 'returns' do + expect(subject.transform(nil, nil)).to be_nil + end + end + + context 'when custom reference is provided' do + it 'updates provided reference' do + hash = { + 'author' => { + 'public_email' => user.email + } + } + + transformer = described_class.new(reference: 'author') + result = transformer.transform(context, hash) + + expect(result['author']).to be_nil + expect(result['author_id']).to eq(user.id) + end + end end end diff --git a/spec/lib/bulk_imports/groups/graphql/get_labels_query_spec.rb b/spec/lib/bulk_imports/groups/graphql/get_labels_query_spec.rb index 247da200d68..85f82be7d18 100644 --- a/spec/lib/bulk_imports/groups/graphql/get_labels_query_spec.rb +++ b/spec/lib/bulk_imports/groups/graphql/get_labels_query_spec.rb @@ -3,15 +3,18 @@ require 'spec_helper' RSpec.describe BulkImports::Groups::Graphql::GetLabelsQuery do - describe '#variables' do - let(:entity) { double(source_full_path: 'test', next_page_for: 'next_page', bulk_import: nil) } - let(:context) { BulkImports::Pipeline::Context.new(entity) } - - it 'returns query variables based on entity information' do - expected = { full_path: entity.source_full_path, cursor: entity.next_page_for } - - expect(described_class.variables(context)).to eq(expected) - end + it 'has a valid query' do + entity = create(:bulk_import_entity) + context = BulkImports::Pipeline::Context.new(entity) + + query = GraphQL::Query.new( + GitlabSchema, + described_class.to_s, + variables: described_class.variables(context) + ) + result = GitlabSchema.static_validator.validate(query) + + expect(result[:errors]).to be_empty end describe '#data_path' do diff --git a/spec/lib/bulk_imports/groups/graphql/get_milestones_query_spec.rb b/spec/lib/bulk_imports/groups/graphql/get_milestones_query_spec.rb new file mode 100644 index 00000000000..a38505fbf85 --- /dev/null +++ b/spec/lib/bulk_imports/groups/graphql/get_milestones_query_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Groups::Graphql::GetMilestonesQuery do + it 'has a valid query' do + entity = create(:bulk_import_entity) + context = BulkImports::Pipeline::Context.new(entity) + + query = GraphQL::Query.new( + GitlabSchema, + described_class.to_s, + variables: described_class.variables(context) + ) + result = GitlabSchema.static_validator.validate(query) + + expect(result[:errors]).to be_empty + end + + describe '#data_path' do + it 'returns data path' do + expected = %w[data group milestones nodes] + + expect(described_class.data_path).to eq(expected) + end + end + + describe '#page_info_path' do + it 'returns pagination information path' do + expected = %w[data group milestones page_info] + + expect(described_class.page_info_path).to eq(expected) + end + end +end diff --git a/spec/lib/bulk_imports/groups/loaders/labels_loader_spec.rb b/spec/lib/bulk_imports/groups/loaders/labels_loader_spec.rb deleted file mode 100644 index ac2f9c8cb1d..00000000000 --- a/spec/lib/bulk_imports/groups/loaders/labels_loader_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe BulkImports::Groups::Loaders::LabelsLoader do - describe '#load' do - let(:user) { create(:user) } - let(:group) { create(:group) } - let(:entity) { create(:bulk_import_entity, group: group) } - let(:context) { BulkImports::Pipeline::Context.new(entity) } - - let(:data) do - { - 'title' => 'label', - 'description' => 'description', - 'color' => '#FFFFFF' - } - end - - it 'creates the label' do - expect { subject.load(context, data) }.to change(Label, :count).by(1) - - label = group.labels.first - - expect(label.title).to eq(data['title']) - expect(label.description).to eq(data['description']) - expect(label.color).to eq(data['color']) - end - end -end diff --git a/spec/lib/bulk_imports/groups/loaders/members_loader_spec.rb b/spec/lib/bulk_imports/groups/loaders/members_loader_spec.rb deleted file mode 100644 index d552578e7be..00000000000 --- a/spec/lib/bulk_imports/groups/loaders/members_loader_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe BulkImports::Groups::Loaders::MembersLoader do - describe '#load' do - let_it_be(:user_importer) { create(:user) } - let_it_be(:user_member) { create(:user) } - let_it_be(:group) { create(:group) } - let_it_be(:bulk_import) { create(:bulk_import, user: user_importer) } - let_it_be(:entity) { create(:bulk_import_entity, bulk_import: bulk_import, group: group) } - let_it_be(:context) { BulkImports::Pipeline::Context.new(entity) } - - let_it_be(:data) do - { - 'user_id' => user_member.id, - 'created_by_id' => user_importer.id, - 'access_level' => 30, - 'created_at' => '2020-01-01T00:00:00Z', - 'updated_at' => '2020-01-01T00:00:00Z', - 'expires_at' => nil - } - end - - it 'does nothing when there is no data' do - expect { subject.load(context, nil) }.not_to change(GroupMember, :count) - end - - it 'creates the member' do - expect { subject.load(context, data) }.to change(GroupMember, :count).by(1) - - member = group.members.last - - expect(member.user).to eq(user_member) - expect(member.created_by).to eq(user_importer) - expect(member.access_level).to eq(30) - expect(member.created_at).to eq('2020-01-01T00:00:00Z') - expect(member.updated_at).to eq('2020-01-01T00:00:00Z') - expect(member.expires_at).to eq(nil) - end - end -end diff --git a/spec/lib/bulk_imports/groups/pipelines/labels_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/labels_pipeline_spec.rb index 63f28916d9a..3327a30f1d5 100644 --- a/spec/lib/bulk_imports/groups/pipelines/labels_pipeline_spec.rb +++ b/spec/lib/bulk_imports/groups/pipelines/labels_pipeline_spec.rb @@ -6,6 +6,7 @@ RSpec.describe BulkImports::Groups::Pipelines::LabelsPipeline do let(:user) { create(:user) } let(:group) { create(:group) } let(:cursor) { 'cursor' } + let(:timestamp) { Time.new(2020, 01, 01).utc } let(:entity) do create( :bulk_import_entity, @@ -20,21 +21,23 @@ RSpec.describe BulkImports::Groups::Pipelines::LabelsPipeline do subject { described_class.new(context) } - def extractor_data(title:, has_next_page:, cursor: nil) - data = [ - { - 'title' => title, - 'description' => 'desc', - 'color' => '#428BCA' - } - ] + def label_data(title) + { + 'title' => title, + 'description' => 'desc', + 'color' => '#428BCA', + 'created_at' => timestamp.to_s, + 'updated_at' => timestamp.to_s + } + end + def extractor_data(title:, has_next_page:, cursor: nil) page_info = { 'end_cursor' => cursor, 'has_next_page' => has_next_page } - BulkImports::Pipeline::ExtractedData.new(data: data, page_info: page_info) + BulkImports::Pipeline::ExtractedData.new(data: [label_data(title)], page_info: page_info) end describe '#run' do @@ -55,6 +58,8 @@ RSpec.describe BulkImports::Groups::Pipelines::LabelsPipeline do expect(label.title).to eq('label2') expect(label.description).to eq('desc') expect(label.color).to eq('#428BCA') + expect(label.created_at).to eq(timestamp) + expect(label.updated_at).to eq(timestamp) end end @@ -90,6 +95,20 @@ RSpec.describe BulkImports::Groups::Pipelines::LabelsPipeline do end end + describe '#load' do + it 'creates the label' do + data = label_data('label') + + expect { subject.load(context, data) }.to change(Label, :count).by(1) + + label = group.labels.first + + data.each do |key, value| + expect(label[key]).to eq(value) + end + end + end + describe 'pipeline parts' do it { expect(described_class).to include_module(BulkImports::Pipeline) } it { expect(described_class).to include_module(BulkImports::Pipeline::Runner) } @@ -110,9 +129,5 @@ RSpec.describe BulkImports::Groups::Pipelines::LabelsPipeline do { klass: BulkImports::Common::Transformers::ProhibitedAttributesTransformer, options: nil } ) end - - it 'has loaders' do - expect(described_class.get_loader).to eq(klass: BulkImports::Groups::Loaders::LabelsLoader, options: nil) - end end end diff --git a/spec/lib/bulk_imports/groups/pipelines/members_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/members_pipeline_spec.rb index 9f498f8154f..74d3e09d263 100644 --- a/spec/lib/bulk_imports/groups/pipelines/members_pipeline_spec.rb +++ b/spec/lib/bulk_imports/groups/pipelines/members_pipeline_spec.rb @@ -37,6 +37,34 @@ RSpec.describe BulkImports::Groups::Pipelines::MembersPipeline do end end + describe '#load' do + it 'does nothing when there is no data' do + expect { subject.load(context, nil) }.not_to change(GroupMember, :count) + end + + it 'creates the member' do + data = { + 'user_id' => member_user1.id, + 'created_by_id' => member_user2.id, + 'access_level' => 30, + 'created_at' => '2020-01-01T00:00:00Z', + 'updated_at' => '2020-01-01T00:00:00Z', + 'expires_at' => nil + } + + expect { subject.load(context, data) }.to change(GroupMember, :count).by(1) + + member = group.members.last + + expect(member.user).to eq(member_user1) + expect(member.created_by).to eq(member_user2) + expect(member.access_level).to eq(30) + expect(member.created_at).to eq('2020-01-01T00:00:00Z') + expect(member.updated_at).to eq('2020-01-01T00:00:00Z') + expect(member.expires_at).to eq(nil) + end + end + describe 'pipeline parts' do it { expect(described_class).to include_module(BulkImports::Pipeline) } it { expect(described_class).to include_module(BulkImports::Pipeline::Runner) } @@ -58,10 +86,6 @@ RSpec.describe BulkImports::Groups::Pipelines::MembersPipeline do { klass: BulkImports::Groups::Transformers::MemberAttributesTransformer, options: nil } ) end - - it 'has loaders' do - expect(described_class.get_loader).to eq(klass: BulkImports::Groups::Loaders::MembersLoader, options: nil) - end end def member_data(email:, has_next_page:, cursor: nil) diff --git a/spec/lib/bulk_imports/groups/pipelines/milestones_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/milestones_pipeline_spec.rb new file mode 100644 index 00000000000..f0c34c65257 --- /dev/null +++ b/spec/lib/bulk_imports/groups/pipelines/milestones_pipeline_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Groups::Pipelines::MilestonesPipeline do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:cursor) { 'cursor' } + let_it_be(:timestamp) { Time.new(2020, 01, 01).utc } + let_it_be(:bulk_import) { create(:bulk_import, user: user) } + + let(:entity) do + create( + :bulk_import_entity, + bulk_import: bulk_import, + source_full_path: 'source/full/path', + destination_name: 'My Destination Group', + destination_namespace: group.full_path, + group: group + ) + end + + let(:context) { BulkImports::Pipeline::Context.new(entity) } + + subject { described_class.new(context) } + + def milestone_data(title) + { + 'title' => title, + 'description' => 'desc', + 'state' => 'closed', + 'start_date' => '2020-10-21', + 'due_date' => '2020-10-22', + 'created_at' => timestamp.to_s, + 'updated_at' => timestamp.to_s + } + end + + def extracted_data(title:, has_next_page:, cursor: nil) + page_info = { + 'end_cursor' => cursor, + 'has_next_page' => has_next_page + } + + BulkImports::Pipeline::ExtractedData.new(data: [milestone_data(title)], page_info: page_info) + end + + before do + group.add_owner(user) + end + + describe '#run' do + it 'imports group milestones' do + first_page = extracted_data(title: 'milestone1', has_next_page: true, cursor: cursor) + last_page = extracted_data(title: 'milestone2', has_next_page: false) + + allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor| + allow(extractor) + .to receive(:extract) + .and_return(first_page, last_page) + end + + expect { subject.run }.to change(Milestone, :count).by(2) + + expect(group.milestones.pluck(:title)).to contain_exactly('milestone1', 'milestone2') + + milestone = group.milestones.last + + expect(milestone.description).to eq('desc') + expect(milestone.state).to eq('closed') + expect(milestone.start_date.to_s).to eq('2020-10-21') + expect(milestone.due_date.to_s).to eq('2020-10-22') + expect(milestone.created_at).to eq(timestamp) + expect(milestone.updated_at).to eq(timestamp) + end + end + + describe '#after_run' do + context 'when extracted data has next page' do + it 'updates tracker information and runs pipeline again' do + data = extracted_data(title: 'milestone', has_next_page: true, cursor: cursor) + + expect(subject).to receive(:run) + + subject.after_run(data) + + tracker = entity.trackers.find_by(relation: :milestones) + + expect(tracker.has_next_page).to eq(true) + expect(tracker.next_page).to eq(cursor) + end + end + + context 'when extracted data has no next page' do + it 'updates tracker information and does not run pipeline' do + data = extracted_data(title: 'milestone', has_next_page: false) + + expect(subject).not_to receive(:run) + + subject.after_run(data) + + tracker = entity.trackers.find_by(relation: :milestones) + + expect(tracker.has_next_page).to eq(false) + expect(tracker.next_page).to be_nil + end + end + end + + describe '#load' do + it 'creates the milestone' do + data = milestone_data('milestone') + + expect { subject.load(context, data) }.to change(Milestone, :count).by(1) + end + + context 'when user is not authorized to create the milestone' do + before do + allow(user).to receive(:can?).with(:admin_milestone, group).and_return(false) + end + + it 'raises NotAllowedError' do + data = extracted_data(title: 'milestone', has_next_page: false) + + expect { subject.load(context, data) }.to raise_error(::BulkImports::Pipeline::NotAllowedError) + end + end + end + + describe 'pipeline parts' do + it { expect(described_class).to include_module(BulkImports::Pipeline) } + it { expect(described_class).to include_module(BulkImports::Pipeline::Runner) } + + it 'has extractors' do + expect(described_class.get_extractor) + .to eq( + klass: BulkImports::Common::Extractors::GraphqlExtractor, + options: { + query: BulkImports::Groups::Graphql::GetMilestonesQuery + } + ) + end + + it 'has transformers' do + expect(described_class.transformers) + .to contain_exactly( + { klass: BulkImports::Common::Transformers::ProhibitedAttributesTransformer, options: nil } + ) + end + end +end diff --git a/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb index 0404c52b895..2a99646bb4a 100644 --- a/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb +++ b/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb @@ -3,9 +3,14 @@ require 'spec_helper' RSpec.describe BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group, path: 'group') } + let_it_be(:parent) { create(:group, name: 'imported-group', path: 'imported-group') } + let(:context) { BulkImports::Pipeline::Context.new(parent_entity) } + + subject { described_class.new(context) } + describe '#run' do - let_it_be(:user) { create(:user) } - let(:parent) { create(:group, name: 'imported-group', path: 'imported-group') } let!(:parent_entity) do create( :bulk_import_entity, @@ -14,8 +19,6 @@ RSpec.describe BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline do ) end - let(:context) { BulkImports::Pipeline::Context.new(parent_entity) } - let(:subgroup_data) do [ { @@ -25,8 +28,6 @@ RSpec.describe BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline do ] end - subject { described_class.new(context) } - before do allow_next_instance_of(BulkImports::Groups::Extractors::SubgroupsExtractor) do |extractor| allow(extractor).to receive(:extract).and_return(subgroup_data) @@ -47,6 +48,29 @@ RSpec.describe BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline do end end + describe '#load' do + let(:parent_entity) { create(:bulk_import_entity, group: group, bulk_import: create(:bulk_import)) } + + it 'creates entities for the given data' do + data = { + source_type: :group_entity, + source_full_path: 'parent/subgroup', + destination_name: 'subgroup', + destination_namespace: parent_entity.group.full_path, + parent_id: parent_entity.id + } + + expect { subject.load(context, data) }.to change(BulkImports::Entity, :count).by(1) + + subgroup_entity = BulkImports::Entity.last + + expect(subgroup_entity.source_full_path).to eq 'parent/subgroup' + expect(subgroup_entity.destination_namespace).to eq 'group' + expect(subgroup_entity.destination_name).to eq 'subgroup' + expect(subgroup_entity.parent_id).to eq parent_entity.id + end + end + describe 'pipeline parts' do it { expect(described_class).to include_module(BulkImports::Pipeline) } it { expect(described_class).to include_module(BulkImports::Pipeline::Runner) } @@ -61,9 +85,5 @@ RSpec.describe BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline do { klass: BulkImports::Groups::Transformers::SubgroupToEntityTransformer, options: nil } ) end - - it 'has loaders' do - expect(described_class.get_loader).to eq(klass: BulkImports::Common::Loaders::EntityLoader, options: nil) - end end end diff --git a/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb b/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb index 5a7a51675d6..b3fe8a2ba25 100644 --- a/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb +++ b/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb @@ -80,14 +80,14 @@ RSpec.describe BulkImports::Groups::Transformers::GroupAttributesTransformer do expect(transformed_data['parent_id']).to eq(parent.id) end - context 'when destination namespace is user namespace' do + context 'when destination namespace is empty' do it 'does not set parent id' do entity = create( :bulk_import_entity, bulk_import: bulk_import, source_full_path: 'source/full/path', destination_name: group.name, - destination_namespace: user.namespace.full_path + destination_namespace: '' ) context = BulkImports::Pipeline::Context.new(entity) diff --git a/spec/lib/bulk_imports/importers/group_importer_spec.rb b/spec/lib/bulk_imports/importers/group_importer_spec.rb index b4fdb7b5e5b..5d501b49e41 100644 --- a/spec/lib/bulk_imports/importers/group_importer_spec.rb +++ b/spec/lib/bulk_imports/importers/group_importer_spec.rb @@ -22,10 +22,13 @@ RSpec.describe BulkImports::Importers::GroupImporter do expect_to_run_pipeline BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline, context: context expect_to_run_pipeline BulkImports::Groups::Pipelines::MembersPipeline, context: context expect_to_run_pipeline BulkImports::Groups::Pipelines::LabelsPipeline, context: context + expect_to_run_pipeline BulkImports::Groups::Pipelines::MilestonesPipeline, context: context if Gitlab.ee? expect_to_run_pipeline('EE::BulkImports::Groups::Pipelines::EpicsPipeline'.constantize, context: context) expect_to_run_pipeline('EE::BulkImports::Groups::Pipelines::EpicAwardEmojiPipeline'.constantize, context: context) + expect_to_run_pipeline('EE::BulkImports::Groups::Pipelines::EpicEventsPipeline'.constantize, context: context) + expect_to_run_pipeline('EE::BulkImports::Groups::Pipelines::IterationsPipeline'.constantize, context: context) end subject.execute diff --git a/spec/lib/bulk_imports/pipeline/runner_spec.rb b/spec/lib/bulk_imports/pipeline/runner_spec.rb index 76e4e64a7d6..59f01c9caaa 100644 --- a/spec/lib/bulk_imports/pipeline/runner_spec.rb +++ b/spec/lib/bulk_imports/pipeline/runner_spec.rb @@ -27,29 +27,31 @@ RSpec.describe BulkImports::Pipeline::Runner do end end - describe 'pipeline runner' do - before do - stub_const('BulkImports::Extractor', extractor) - stub_const('BulkImports::Transformer', transformer) - stub_const('BulkImports::Loader', loader) - - pipeline = Class.new do - include BulkImports::Pipeline + before do + stub_const('BulkImports::Extractor', extractor) + stub_const('BulkImports::Transformer', transformer) + stub_const('BulkImports::Loader', loader) - extractor BulkImports::Extractor - transformer BulkImports::Transformer - loader BulkImports::Loader + pipeline = Class.new do + include BulkImports::Pipeline - def after_run(_); end - end + extractor BulkImports::Extractor + transformer BulkImports::Transformer + loader BulkImports::Loader - stub_const('BulkImports::MyPipeline', pipeline) + def after_run(_); end end - context 'when entity is not marked as failed' do - let(:entity) { create(:bulk_import_entity) } - let(:context) { BulkImports::Pipeline::Context.new(entity) } + stub_const('BulkImports::MyPipeline', pipeline) + end + let_it_be_with_refind(:entity) { create(:bulk_import_entity) } + let(:context) { BulkImports::Pipeline::Context.new(entity, extra: :data) } + + subject { BulkImports::MyPipeline.new(context) } + + describe 'pipeline runner' do + context 'when entity is not marked as failed' do it 'runs pipeline extractor, transformer, loader' do extracted_data = BulkImports::Pipeline::ExtractedData.new(data: { foo: :bar }) @@ -76,58 +78,61 @@ RSpec.describe BulkImports::Pipeline::Runner do expect_next_instance_of(Gitlab::Import::Logger) do |logger| expect(logger).to receive(:info) .with( - bulk_import_entity_id: entity.id, - bulk_import_entity_type: 'group_entity', - message: 'Pipeline started', - pipeline_class: 'BulkImports::MyPipeline' + log_params( + context, + message: 'Pipeline started', + pipeline_class: 'BulkImports::MyPipeline' + ) ) expect(logger).to receive(:info) .with( - bulk_import_entity_id: entity.id, - bulk_import_entity_type: 'group_entity', - pipeline_class: 'BulkImports::MyPipeline', - pipeline_step: :extractor, - step_class: 'BulkImports::Extractor' + log_params( + context, + pipeline_class: 'BulkImports::MyPipeline', + pipeline_step: :extractor, + step_class: 'BulkImports::Extractor' + ) ) expect(logger).to receive(:info) .with( - bulk_import_entity_id: entity.id, - bulk_import_entity_type: 'group_entity', - pipeline_class: 'BulkImports::MyPipeline', - pipeline_step: :transformer, - step_class: 'BulkImports::Transformer' + log_params( + context, + pipeline_class: 'BulkImports::MyPipeline', + pipeline_step: :transformer, + step_class: 'BulkImports::Transformer' + ) ) expect(logger).to receive(:info) .with( - bulk_import_entity_id: entity.id, - bulk_import_entity_type: 'group_entity', - pipeline_class: 'BulkImports::MyPipeline', - pipeline_step: :loader, - step_class: 'BulkImports::Loader' + log_params( + context, + pipeline_class: 'BulkImports::MyPipeline', + pipeline_step: :loader, + step_class: 'BulkImports::Loader' + ) ) expect(logger).to receive(:info) .with( - bulk_import_entity_id: entity.id, - bulk_import_entity_type: 'group_entity', - pipeline_class: 'BulkImports::MyPipeline', - pipeline_step: :after_run + log_params( + context, + pipeline_class: 'BulkImports::MyPipeline', + pipeline_step: :after_run + ) ) expect(logger).to receive(:info) .with( - bulk_import_entity_id: entity.id, - bulk_import_entity_type: 'group_entity', - message: 'Pipeline finished', - pipeline_class: 'BulkImports::MyPipeline' + log_params( + context, + message: 'Pipeline finished', + pipeline_class: 'BulkImports::MyPipeline' + ) ) end - BulkImports::MyPipeline.new(context).run + subject.run end context 'when exception is raised' do - let(:entity) { create(:bulk_import_entity, :created) } - let(:context) { BulkImports::Pipeline::Context.new(entity) } - before do allow_next_instance_of(BulkImports::Extractor) do |extractor| allow(extractor).to receive(:extract).with(context).and_raise(StandardError, 'Error!') @@ -135,7 +140,21 @@ RSpec.describe BulkImports::Pipeline::Runner do end it 'logs import failure' do - BulkImports::MyPipeline.new(context).run + expect_next_instance_of(Gitlab::Import::Logger) do |logger| + expect(logger).to receive(:error) + .with( + log_params( + context, + pipeline_step: :extractor, + pipeline_class: 'BulkImports::MyPipeline', + exception_class: 'StandardError', + exception_message: 'Error!' + ) + ) + end + + expect { subject.run } + .to change(entity.failures, :count).by(1) failure = entity.failures.first @@ -152,29 +171,29 @@ RSpec.describe BulkImports::Pipeline::Runner do end it 'marks entity as failed' do - BulkImports::MyPipeline.new(context).run - - expect(entity.failed?).to eq(true) + expect { subject.run } + .to change(entity, :status_name).to(:failed) end it 'logs warn message' do expect_next_instance_of(Gitlab::Import::Logger) do |logger| expect(logger).to receive(:warn) .with( - message: 'Pipeline failed', - pipeline_class: 'BulkImports::MyPipeline', - bulk_import_entity_id: entity.id, - bulk_import_entity_type: entity.source_type + log_params( + context, + message: 'Pipeline failed', + pipeline_class: 'BulkImports::MyPipeline' + ) ) end - BulkImports::MyPipeline.new(context).run + subject.run end end context 'when pipeline is not marked to abort on failure' do - it 'marks entity as failed' do - BulkImports::MyPipeline.new(context).run + it 'does not mark entity as failed' do + subject.run expect(entity.failed?).to eq(false) end @@ -183,24 +202,31 @@ RSpec.describe BulkImports::Pipeline::Runner do end context 'when entity is marked as failed' do - let(:entity) { create(:bulk_import_entity) } - let(:context) { BulkImports::Pipeline::Context.new(entity) } - it 'logs and returns without execution' do - allow(entity).to receive(:failed?).and_return(true) + entity.fail_op! expect_next_instance_of(Gitlab::Import::Logger) do |logger| expect(logger).to receive(:info) .with( - message: 'Skipping due to failed pipeline status', - pipeline_class: 'BulkImports::MyPipeline', - bulk_import_entity_id: entity.id, - bulk_import_entity_type: 'group_entity' + log_params( + context, + message: 'Skipping due to failed pipeline status', + pipeline_class: 'BulkImports::MyPipeline' + ) ) end - BulkImports::MyPipeline.new(context).run + subject.run end end end + + def log_params(context, extra = {}) + { + bulk_import_id: context.bulk_import.id, + bulk_import_entity_id: context.entity.id, + bulk_import_entity_type: context.entity.source_type, + context_extra: context.extra + }.merge(extra) + end end diff --git a/spec/lib/bulk_imports/pipeline_spec.rb b/spec/lib/bulk_imports/pipeline_spec.rb index 3811a02a7fd..c882e3d26ea 100644 --- a/spec/lib/bulk_imports/pipeline_spec.rb +++ b/spec/lib/bulk_imports/pipeline_spec.rb @@ -3,25 +3,25 @@ require 'spec_helper' RSpec.describe BulkImports::Pipeline do - describe 'pipeline attributes' do - before do - stub_const('BulkImports::Extractor', Class.new) - stub_const('BulkImports::Transformer', Class.new) - stub_const('BulkImports::Loader', Class.new) - - klass = Class.new do - include BulkImports::Pipeline + before do + stub_const('BulkImports::Extractor', Class.new) + stub_const('BulkImports::Transformer', Class.new) + stub_const('BulkImports::Loader', Class.new) - abort_on_failure! + klass = Class.new do + include BulkImports::Pipeline - extractor BulkImports::Extractor, { foo: :bar } - transformer BulkImports::Transformer, { foo: :bar } - loader BulkImports::Loader, { foo: :bar } - end + abort_on_failure! - stub_const('BulkImports::MyPipeline', klass) + extractor BulkImports::Extractor, foo: :bar + transformer BulkImports::Transformer, foo: :bar + loader BulkImports::Loader, foo: :bar end + stub_const('BulkImports::MyPipeline', klass) + end + + describe 'pipeline attributes' do describe 'getters' do it 'retrieves class attributes' do expect(BulkImports::MyPipeline.get_extractor).to eq({ klass: BulkImports::Extractor, options: { foo: :bar } }) @@ -29,6 +29,27 @@ RSpec.describe BulkImports::Pipeline do expect(BulkImports::MyPipeline.get_loader).to eq({ klass: BulkImports::Loader, options: { foo: :bar } }) expect(BulkImports::MyPipeline.abort_on_failure?).to eq(true) end + + context 'when extractor and loader are defined within the pipeline' do + before do + klass = Class.new do + include BulkImports::Pipeline + + def extract; end + + def load; end + end + + stub_const('BulkImports::AnotherPipeline', klass) + end + + it 'returns itself when retrieving extractor & loader' do + pipeline = BulkImports::AnotherPipeline.new(nil) + + expect(pipeline.send(:extractor)).to eq(pipeline) + expect(pipeline.send(:loader)).to eq(pipeline) + end + end end describe 'setters' do @@ -54,4 +75,69 @@ RSpec.describe BulkImports::Pipeline do end end end + + describe '#instantiate' do + context 'when options are present' do + it 'instantiates new object with options' do + expect(BulkImports::Extractor).to receive(:new).with(foo: :bar) + expect(BulkImports::Transformer).to receive(:new).with(foo: :bar) + expect(BulkImports::Loader).to receive(:new).with(foo: :bar) + + pipeline = BulkImports::MyPipeline.new(nil) + + pipeline.send(:extractor) + pipeline.send(:transformers) + pipeline.send(:loader) + end + end + + context 'when options are missing' do + before do + klass = Class.new do + include BulkImports::Pipeline + + extractor BulkImports::Extractor + transformer BulkImports::Transformer + loader BulkImports::Loader + end + + stub_const('BulkImports::NoOptionsPipeline', klass) + end + + it 'instantiates new object without options' do + expect(BulkImports::Extractor).to receive(:new).with(no_args) + expect(BulkImports::Transformer).to receive(:new).with(no_args) + expect(BulkImports::Loader).to receive(:new).with(no_args) + + pipeline = BulkImports::NoOptionsPipeline.new(nil) + + pipeline.send(:extractor) + pipeline.send(:transformers) + pipeline.send(:loader) + end + end + end + + describe '#transformers' do + before do + klass = Class.new do + include BulkImports::Pipeline + + transformer BulkImports::Transformer + + def transform; end + end + + stub_const('BulkImports::TransformersPipeline', klass) + end + + it 'has instance transform method first to run' do + transformer = double + allow(BulkImports::Transformer).to receive(:new).and_return(transformer) + + pipeline = BulkImports::TransformersPipeline.new(nil) + + expect(pipeline.send(:transformers)).to eq([pipeline, transformer]) + end + end end diff --git a/spec/lib/sentry/api_urls_spec.rb b/spec/lib/error_tracking/sentry_client/api_urls_spec.rb index d56b4397e1c..bd701748dc2 100644 --- a/spec/lib/sentry/api_urls_spec.rb +++ b/spec/lib/error_tracking/sentry_client/api_urls_spec.rb @@ -2,13 +2,13 @@ require 'spec_helper' -RSpec.describe Sentry::ApiUrls do +RSpec.describe ErrorTracking::SentryClient::ApiUrls do let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' } let(:token) { 'test-token' } let(:issue_id) { '123456' } let(:issue_id_with_reserved_chars) { '123$%' } let(:escaped_issue_id) { '123%24%25' } - let(:api_urls) { Sentry::ApiUrls.new(sentry_url) } + let(:api_urls) { described_class.new(sentry_url) } # Sentry API returns 404 if there are extra slashes in the URL! shared_examples 'correct url with extra slashes' do diff --git a/spec/lib/sentry/client/event_spec.rb b/spec/lib/error_tracking/sentry_client/event_spec.rb index 07ed331c44c..64e674f1e9b 100644 --- a/spec/lib/sentry/client/event_spec.rb +++ b/spec/lib/error_tracking/sentry_client/event_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Sentry::Client do +RSpec.describe ErrorTracking::SentryClient do include SentryClientHelpers let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } diff --git a/spec/lib/sentry/client/issue_link_spec.rb b/spec/lib/error_tracking/sentry_client/issue_link_spec.rb index fe3abe7cb23..f86d328ef89 100644 --- a/spec/lib/sentry/client/issue_link_spec.rb +++ b/spec/lib/error_tracking/sentry_client/issue_link_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Sentry::Client::IssueLink do +RSpec.describe ErrorTracking::SentryClient::IssueLink do include SentryClientHelpers let_it_be(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } diff --git a/spec/lib/sentry/client/issue_spec.rb b/spec/lib/error_tracking/sentry_client/issue_spec.rb index dedef905c95..e54296c58e0 100644 --- a/spec/lib/sentry/client/issue_spec.rb +++ b/spec/lib/error_tracking/sentry_client/issue_spec.rb @@ -2,12 +2,12 @@ require 'spec_helper' -RSpec.describe Sentry::Client::Issue do +RSpec.describe ErrorTracking::SentryClient::Issue do include SentryClientHelpers let(:token) { 'test-token' } let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0' } - let(:client) { Sentry::Client.new(sentry_url, token) } + let(:client) { ErrorTracking::SentryClient.new(sentry_url, token) } let(:issue_id) { 11 } describe '#list_issues' do @@ -136,7 +136,7 @@ RSpec.describe Sentry::Client::Issue do subject { client.list_issues(issue_status: issue_status, limit: limit, sort: 'fish') } it 'throws an error' do - expect { subject }.to raise_error(Sentry::Client::BadRequestError, 'Invalid value for sort param') + expect { subject }.to raise_error(ErrorTracking::SentryClient::BadRequestError, 'Invalid value for sort param') end end @@ -164,7 +164,7 @@ RSpec.describe Sentry::Client::Issue do end it 'raises exception' do - expect { subject }.to raise_error(Sentry::Client::MissingKeysError, 'Sentry API response is missing keys. key not found: "id"') + expect { subject }.to raise_error(ErrorTracking::SentryClient::MissingKeysError, 'Sentry API response is missing keys. key not found: "id"') end end @@ -173,7 +173,7 @@ RSpec.describe Sentry::Client::Issue do deep_size = double('Gitlab::Utils::DeepSize', valid?: false) allow(Gitlab::Utils::DeepSize).to receive(:new).with(sentry_api_response).and_return(deep_size) - expect { subject }.to raise_error(Sentry::Client::ResponseInvalidSizeError, 'Sentry API response is too big. Limit is 1 MB.') + expect { subject }.to raise_error(ErrorTracking::SentryClient::ResponseInvalidSizeError, 'Sentry API response is too big. Limit is 1 MB.') end end diff --git a/spec/lib/sentry/pagination_parser_spec.rb b/spec/lib/error_tracking/sentry_client/pagination_parser_spec.rb index c4ed24827bb..c4b771d5b93 100644 --- a/spec/lib/sentry/pagination_parser_spec.rb +++ b/spec/lib/error_tracking/sentry_client/pagination_parser_spec.rb @@ -2,7 +2,7 @@ require 'fast_spec_helper' -RSpec.describe Sentry::PaginationParser do +RSpec.describe ErrorTracking::SentryClient::PaginationParser do describe '.parse' do subject { described_class.parse(headers) } diff --git a/spec/lib/sentry/client/projects_spec.rb b/spec/lib/error_tracking/sentry_client/projects_spec.rb index ea2c5ccb81e..247f9c1c085 100644 --- a/spec/lib/sentry/client/projects_spec.rb +++ b/spec/lib/error_tracking/sentry_client/projects_spec.rb @@ -2,12 +2,12 @@ require 'spec_helper' -RSpec.describe Sentry::Client::Projects do +RSpec.describe ErrorTracking::SentryClient::Projects do include SentryClientHelpers let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } let(:token) { 'test-token' } - let(:client) { Sentry::Client.new(sentry_url, token) } + let(:client) { ErrorTracking::SentryClient.new(sentry_url, token) } let(:projects_sample_response) do Gitlab::Utils.deep_indifferent_access( Gitlab::Json.parse(fixture_file('sentry/list_projects_sample_response.json')) @@ -44,7 +44,7 @@ RSpec.describe Sentry::Client::Projects do end it 'raises exception' do - expect { subject }.to raise_error(Sentry::Client::MissingKeysError, 'Sentry API response is missing keys. key not found: "slug"') + expect { subject }.to raise_error(ErrorTracking::SentryClient::MissingKeysError, 'Sentry API response is missing keys. key not found: "slug"') end end diff --git a/spec/lib/sentry/client/repo_spec.rb b/spec/lib/error_tracking/sentry_client/repo_spec.rb index 956c0b6eee1..9a1c7a69c3d 100644 --- a/spec/lib/sentry/client/repo_spec.rb +++ b/spec/lib/error_tracking/sentry_client/repo_spec.rb @@ -2,12 +2,12 @@ require 'spec_helper' -RSpec.describe Sentry::Client::Repo do +RSpec.describe ErrorTracking::SentryClient::Repo do include SentryClientHelpers let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } let(:token) { 'test-token' } - let(:client) { Sentry::Client.new(sentry_url, token) } + let(:client) { ErrorTracking::SentryClient.new(sentry_url, token) } let(:repos_sample_response) { Gitlab::Json.parse(fixture_file('sentry/repos_sample_response.json')) } describe '#repos' do diff --git a/spec/lib/sentry/client_spec.rb b/spec/lib/error_tracking/sentry_client_spec.rb index cddcb6e98fa..9ffd756f057 100644 --- a/spec/lib/sentry/client_spec.rb +++ b/spec/lib/error_tracking/sentry_client_spec.rb @@ -2,11 +2,11 @@ require 'spec_helper' -RSpec.describe Sentry::Client do +RSpec.describe ErrorTracking::SentryClient do let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } let(:token) { 'test-token' } - subject { Sentry::Client.new(sentry_url, token) } + subject { described_class.new(sentry_url, token) } it { is_expected.to respond_to :projects } it { is_expected.to respond_to :list_issues } diff --git a/spec/lib/expand_variables_spec.rb b/spec/lib/expand_variables_spec.rb index b603325cdb8..407187ea05f 100644 --- a/spec/lib/expand_variables_spec.rb +++ b/spec/lib/expand_variables_spec.rb @@ -82,6 +82,13 @@ RSpec.describe ExpandVariables do value: 'key$variable', result: 'keyvalue', variables: -> { [{ key: 'variable', value: 'value' }] } + }, + "simple expansion using Collection": { + value: 'key$variable', + result: 'keyvalue', + variables: Gitlab::Ci::Variables::Collection.new([ + { key: 'variable', value: 'value' } + ]) } } end diff --git a/spec/lib/feature_spec.rb b/spec/lib/feature_spec.rb index 1bcb2223012..3e158391d7f 100644 --- a/spec/lib/feature_spec.rb +++ b/spec/lib/feature_spec.rb @@ -269,7 +269,7 @@ RSpec.describe Feature, stub_feature_flags: false do end it 'when invalid type is used' do - expect { described_class.enabled?(:my_feature_flag, type: :licensed) } + expect { described_class.enabled?(:my_feature_flag, type: :ops) } .to raise_error(/The `type:` of/) end diff --git a/spec/lib/generators/gitlab/usage_metric_definition_generator_spec.rb b/spec/lib/generators/gitlab/usage_metric_definition_generator_spec.rb new file mode 100644 index 00000000000..b62eac14e3e --- /dev/null +++ b/spec/lib/generators/gitlab/usage_metric_definition_generator_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'generator_helper' + +RSpec.describe Gitlab::UsageMetricDefinitionGenerator do + describe 'Validation' do + let(:key_path) { 'counter.category.event' } + let(:dir) { '7d' } + let(:options) { [key_path, '--dir', dir, '--pretend'] } + + subject { described_class.start(options) } + + it 'does not raise an error' do + expect { subject }.not_to raise_error + end + + context 'with a missing directory' do + let(:options) { [key_path, '--pretend'] } + + it 'raises an error' do + expect { subject }.to raise_error(RuntimeError) + end + end + + context 'with an invalid directory' do + let(:dir) { '8d' } + + it 'raises an error' do + expect { subject }.to raise_error(RuntimeError) + end + end + + context 'with an already existing metric with the same key_path' do + before do + allow(Gitlab::Usage::MetricDefinition).to receive(:definitions).and_return(Hash[key_path, 'definition']) + end + + it 'raises an error' do + expect { subject }.to raise_error(RuntimeError) + end + end + end + + describe 'Name suggestions' do + let(:temp_dir) { Dir.mktmpdir } + + before do + stub_const("#{described_class}::TOP_LEVEL_DIR", temp_dir) + end + + context 'with product_intelligence_metrics_names_suggestions feature ON' do + it 'adds name key to metric definition' do + stub_feature_flags(product_intelligence_metrics_names_suggestions: true) + + expect(::Gitlab::Usage::Metrics::NamesSuggestions::Generator).to receive(:generate).and_return('some name') + described_class.new(['counts_weekly.test_metric'], { 'dir' => '7d' }).invoke_all + metric_definition_path = Dir.glob(File.join(temp_dir, 'metrics/counts_7d/*_test_metric.yml')).first + + expect(YAML.safe_load(File.read(metric_definition_path))).to include("name" => "some name") + end + end + + context 'with product_intelligence_metrics_names_suggestions feature OFF' do + it 'adds name key to metric definition' do + stub_feature_flags(product_intelligence_metrics_names_suggestions: false) + + expect(::Gitlab::Usage::Metrics::NamesSuggestions::Generator).not_to receive(:generate) + described_class.new(['counts_weekly.test_metric'], { 'dir' => '7d' }).invoke_all + metric_definition_path = Dir.glob(File.join(temp_dir, 'metrics/counts_7d/*_test_metric.yml')).first + + expect(YAML.safe_load(File.read(metric_definition_path)).keys).not_to include(:name) + end + end + end +end diff --git a/spec/lib/gitlab/alert_management/payload/generic_spec.rb b/spec/lib/gitlab/alert_management/payload/generic_spec.rb index d022c629458..b0c238c62c8 100644 --- a/spec/lib/gitlab/alert_management/payload/generic_spec.rb +++ b/spec/lib/gitlab/alert_management/payload/generic_spec.rb @@ -13,7 +13,7 @@ RSpec.describe Gitlab::AlertManagement::Payload::Generic do describe '#title' do subject { parsed_payload.title } - it_behaves_like 'parsable alert payload field with fallback', 'New: Incident', 'title' + it_behaves_like 'parsable alert payload field with fallback', 'New: Alert', 'title' end describe '#severity' do diff --git a/spec/lib/gitlab/analytics/cycle_analytics/average_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/average_spec.rb new file mode 100644 index 00000000000..e2fdd4918d5 --- /dev/null +++ b/spec/lib/gitlab/analytics/cycle_analytics/average_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Analytics::CycleAnalytics::Average do + let_it_be(:project) { create(:project) } + + let_it_be(:issue_1) do + # Duration: 10 days + create(:issue, project: project, created_at: 20.days.ago).tap do |issue| + issue.metrics.update!(first_mentioned_in_commit_at: 10.days.ago) + end + end + + let_it_be(:issue_2) do + # Duration: 5 days + create(:issue, project: project, created_at: 20.days.ago).tap do |issue| + issue.metrics.update!(first_mentioned_in_commit_at: 15.days.ago) + end + end + + let(:stage) do + build( + :cycle_analytics_project_stage, + start_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents::IssueCreated.identifier, + end_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents::IssueFirstMentionedInCommit.identifier, + project: project + ) + end + + let(:query) { Issue.joins(:metrics).in_projects(project.id) } + + around do |example| + freeze_time { example.run } + end + + subject(:average) { described_class.new(stage: stage, query: query) } + + describe '#seconds' do + subject(:average_duration_in_seconds) { average.seconds } + + context 'when no results' do + let(:query) { Issue.none } + + it { is_expected.to eq(nil) } + end + + context 'returns the average duration in seconds' do + it { is_expected.to be_within(0.5).of(7.5.days.to_f) } + end + end + + describe '#days' do + subject(:average_duration_in_days) { average.days } + + context 'when no results' do + let(:query) { Issue.none } + + it { is_expected.to eq(nil) } + end + + context 'returns the average duration in days' do + it { is_expected.to be_within(0.01).of(7.5) } + end + end +end diff --git a/spec/lib/gitlab/analytics/cycle_analytics/sorting_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/sorting_spec.rb new file mode 100644 index 00000000000..8f5be709a11 --- /dev/null +++ b/spec/lib/gitlab/analytics/cycle_analytics/sorting_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Analytics::CycleAnalytics::Sorting do + let(:stage) { build(:cycle_analytics_project_stage, start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged) } + + subject(:order_values) { described_class.apply(MergeRequest.joins(:metrics), stage, sort, direction).order_values } + + context 'when invalid sorting params are given' do + let(:sort) { :unknown_sort } + let(:direction) { :unknown_direction } + + it 'falls back to end_event DESC sorting' do + expect(order_values).to eq([stage.end_event.timestamp_projection.desc]) + end + end + + context 'sorting end_event' do + let(:sort) { :end_event } + + context 'direction desc' do + let(:direction) { :desc } + + specify do + expect(order_values).to eq([stage.end_event.timestamp_projection.desc]) + end + end + + context 'direction asc' do + let(:direction) { :asc } + + specify do + expect(order_values).to eq([stage.end_event.timestamp_projection.asc]) + end + end + end + + context 'sorting duration' do + let(:sort) { :duration } + + context 'direction desc' do + let(:direction) { :desc } + + specify do + expect(order_values).to eq([Arel::Nodes::Subtraction.new(stage.end_event.timestamp_projection, stage.start_event.timestamp_projection).desc]) + end + end + + context 'direction asc' do + let(:direction) { :asc } + + specify do + expect(order_values).to eq([Arel::Nodes::Subtraction.new(stage.end_event.timestamp_projection, stage.start_event.timestamp_projection).asc]) + end + end + end +end diff --git a/spec/lib/gitlab/analytics/instance_statistics/workers_argument_builder_spec.rb b/spec/lib/gitlab/analytics/usage_trends/workers_argument_builder_spec.rb index 115c8145f59..34c5bd6c6ae 100644 --- a/spec/lib/gitlab/analytics/instance_statistics/workers_argument_builder_spec.rb +++ b/spec/lib/gitlab/analytics/usage_trends/workers_argument_builder_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::Analytics::InstanceStatistics::WorkersArgumentBuilder do +RSpec.describe Gitlab::Analytics::UsageTrends::WorkersArgumentBuilder do context 'when no measurement identifiers are given' do it 'returns empty array' do expect(described_class.new(measurement_identifiers: []).execute).to be_empty @@ -16,8 +16,8 @@ RSpec.describe Gitlab::Analytics::InstanceStatistics::WorkersArgumentBuilder do let_it_be(:project_3) { create(:project, namespace: user_1.namespace, creator: user_1) } let(:recorded_at) { 2.days.ago } - let(:projects_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:projects) } - let(:users_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:users) } + let(:projects_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:projects) } + let(:users_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:users) } let(:measurement_identifiers) { [projects_measurement_identifier, users_measurement_identifier] } subject { described_class.new(measurement_identifiers: measurement_identifiers, recorded_at: recorded_at).execute } @@ -46,19 +46,19 @@ RSpec.describe Gitlab::Analytics::InstanceStatistics::WorkersArgumentBuilder do context 'when custom min and max queries are present' do let(:min_id) { User.second.id } let(:max_id) { User.maximum(:id) } - let(:users_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:users) } + let(:users_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:users) } before do create_list(:user, 2) min_max_queries = { - ::Analytics::InstanceStatistics::Measurement.identifiers[:users] => { + ::Analytics::UsageTrends::Measurement.identifiers[:users] => { minimum_query: -> { min_id }, maximum_query: -> { max_id } } } - allow(::Analytics::InstanceStatistics::Measurement).to receive(:identifier_min_max_queries) { min_max_queries } + allow(::Analytics::UsageTrends::Measurement).to receive(:identifier_min_max_queries) { min_max_queries } end subject do diff --git a/spec/lib/gitlab/application_context_spec.rb b/spec/lib/gitlab/application_context_spec.rb index 88f865adea7..0fbbc67ef6a 100644 --- a/spec/lib/gitlab/application_context_spec.rb +++ b/spec/lib/gitlab/application_context_spec.rb @@ -30,7 +30,7 @@ RSpec.describe Gitlab::ApplicationContext do describe '.push' do it 'passes the expected context on to labkit' do fake_proc = duck_type(:call) - expected_context = { user: fake_proc } + expected_context = { user: fake_proc, client_id: fake_proc } expect(Labkit::Context).to receive(:push).with(expected_context) @@ -92,6 +92,34 @@ RSpec.describe Gitlab::ApplicationContext do expect(result(context)) .to include(project: project.full_path, root_namespace: project.full_path_components.first) end + + describe 'setting the client' do + let_it_be(:remote_ip) { '127.0.0.1' } + let_it_be(:runner) { create(:ci_runner) } + let_it_be(:options) { { remote_ip: remote_ip, runner: runner, user: user } } + + using RSpec::Parameterized::TableSyntax + + where(:provided_options, :client) do + [:remote_ip] | :remote_ip + [:remote_ip, :runner] | :runner + [:remote_ip, :runner, :user] | :user + end + + with_them do + it 'sets the client_id to the expected value' do + context = described_class.new(**options.slice(*provided_options)) + + client_id = case client + when :remote_ip then "ip/#{remote_ip}" + when :runner then "runner/#{runner.id}" + when :user then "user/#{user.id}" + end + + expect(result(context)[:client_id]).to eq(client_id) + end + end + end end describe '#use' do diff --git a/spec/lib/gitlab/auth/o_auth/user_spec.rb b/spec/lib/gitlab/auth/o_auth/user_spec.rb index 6c6cee9c273..7a8e6e77d52 100644 --- a/spec/lib/gitlab/auth/o_auth/user_spec.rb +++ b/spec/lib/gitlab/auth/o_auth/user_spec.rb @@ -995,6 +995,23 @@ RSpec.describe Gitlab::Auth::OAuth::User do end end + context 'when gl_user is nil' do + # We can't use `allow_next_instance_of` here because the stubbed method is called inside `initialize`. + # When the class calls `gl_user` during `initialize`, the `nil` value is overwritten and we do not see expected results from the spec. + # So we use `allow_any_instance_of` to preserve the `nil` value to test the behavior when `gl_user` is nil. + + # rubocop:disable RSpec/AnyInstanceOf + before do + allow_any_instance_of(described_class).to receive(:gl_user) { nil } + allow_any_instance_of(described_class).to receive(:sync_profile_from_provider?) { true } # to make the code flow proceed until gl_user.build_user_synced_attributes_metadata is called + end + # rubocop:enable RSpec/AnyInstanceOf + + it 'does not raise NoMethodError' do + expect { oauth_user }.not_to raise_error + end + end + describe '._uid_and_provider' do let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') } diff --git a/spec/lib/gitlab/avatar_cache_spec.rb b/spec/lib/gitlab/avatar_cache_spec.rb new file mode 100644 index 00000000000..ffe6f81b6e7 --- /dev/null +++ b/spec/lib/gitlab/avatar_cache_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Gitlab::AvatarCache, :clean_gitlab_redis_cache do + def with(&blk) + Gitlab::Redis::Cache.with(&blk) # rubocop:disable CodeReuse/ActiveRecord + end + + def read(key, subkey) + with do |redis| + redis.hget(key, subkey) + end + end + + let(:thing) { double("thing", avatar_path: avatar_path) } + let(:avatar_path) { "/avatars/my_fancy_avatar.png" } + let(:key) { described_class.send(:email_key, "foo@bar.com") } + + let(:perform_fetch) do + described_class.by_email("foo@bar.com", 20, 2, true) do + thing.avatar_path + end + end + + describe "#by_email" do + it "writes a new value into the cache" do + expect(read(key, "20:2:true")).to eq(nil) + + perform_fetch + + expect(read(key, "20:2:true")).to eq(avatar_path) + end + + it "finds the cached value and doesn't execute the block" do + expect(thing).to receive(:avatar_path).once + + described_class.by_email("foo@bar.com", 20, 2, true) do + thing.avatar_path + end + + described_class.by_email("foo@bar.com", 20, 2, true) do + thing.avatar_path + end + end + + it "finds the cached value in the request store and doesn't execute the block" do + expect(thing).to receive(:avatar_path).once + + Gitlab::WithRequestStore.with_request_store do + described_class.by_email("foo@bar.com", 20, 2, true) do + thing.avatar_path + end + + described_class.by_email("foo@bar.com", 20, 2, true) do + thing.avatar_path + end + + expect(Gitlab::SafeRequestStore.read([key, "20:2:true"])).to eq(avatar_path) + end + end + end + + describe "#delete_by_email" do + subject { described_class.delete_by_email(*emails) } + + before do + perform_fetch + end + + context "no emails, somehow" do + let(:emails) { [] } + + it { is_expected.to eq(0) } + end + + context "single email" do + let(:emails) { "foo@bar.com" } + + it "removes the email" do + expect(read(key, "20:2:true")).to eq(avatar_path) + + expect(subject).to eq(1) + + expect(read(key, "20:2:true")).to eq(nil) + end + end + + context "multiple emails" do + let(:emails) { ["foo@bar.com", "missing@baz.com"] } + + it "removes the emails it finds" do + expect(read(key, "20:2:true")).to eq(avatar_path) + + expect(subject).to eq(1) + + expect(read(key, "20:2:true")).to eq(nil) + end + end + end +end diff --git a/spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb b/spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb new file mode 100644 index 00000000000..8febe850e04 --- /dev/null +++ b/spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchingStrategy, '#next_batch' do + let(:batching_strategy) { described_class.new } + let(:namespaces) { table(:namespaces) } + + let!(:namespace1) { namespaces.create!(name: 'batchtest1', path: 'batch-test1') } + let!(:namespace2) { namespaces.create!(name: 'batchtest2', path: 'batch-test2') } + let!(:namespace3) { namespaces.create!(name: 'batchtest3', path: 'batch-test3') } + let!(:namespace4) { namespaces.create!(name: 'batchtest4', path: 'batch-test4') } + + context 'when starting on the first batch' do + it 'returns the bounds of the next batch' do + batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace1.id, batch_size: 3) + + expect(batch_bounds).to eq([namespace1.id, namespace3.id]) + end + end + + context 'when additional batches remain' do + it 'returns the bounds of the next batch' do + batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace2.id, batch_size: 3) + + expect(batch_bounds).to eq([namespace2.id, namespace4.id]) + end + end + + context 'when on the final batch' do + it 'returns the bounds of the next batch' do + batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace4.id, batch_size: 3) + + expect(batch_bounds).to eq([namespace4.id, namespace4.id]) + end + end + + context 'when no additional batches remain' do + it 'returns nil' do + batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace4.id + 1, batch_size: 1) + + expect(batch_bounds).to be_nil + end + end +end diff --git a/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb b/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb index 110a1ff8a08..7ad93c3124a 100644 --- a/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb +++ b/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb @@ -38,22 +38,9 @@ RSpec.describe Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJo describe '#perform' do let(:migration_class) { described_class.name } - let!(:job1) do - table(:background_migration_jobs).create!( - class_name: migration_class, - arguments: [1, 10, table_name, 'id', 'id', 'id_convert_to_bigint', sub_batch_size] - ) - end - - let!(:job2) do - table(:background_migration_jobs).create!( - class_name: migration_class, - arguments: [11, 20, table_name, 'id', 'id', 'id_convert_to_bigint', sub_batch_size] - ) - end it 'copies all primary keys in range' do - subject.perform(12, 15, table_name, 'id', 'id', 'id_convert_to_bigint', sub_batch_size) + subject.perform(12, 15, table_name, 'id', sub_batch_size, 'id', 'id_convert_to_bigint') expect(test_table.where('id = id_convert_to_bigint').pluck(:id)).to contain_exactly(12, 15) expect(test_table.where(id_convert_to_bigint: 0).pluck(:id)).to contain_exactly(11, 19) @@ -61,7 +48,7 @@ RSpec.describe Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJo end it 'copies all foreign keys in range' do - subject.perform(10, 14, table_name, 'id', 'fk', 'fk_convert_to_bigint', sub_batch_size) + subject.perform(10, 14, table_name, 'id', sub_batch_size, 'fk', 'fk_convert_to_bigint') expect(test_table.where('fk = fk_convert_to_bigint').pluck(:id)).to contain_exactly(11, 12) expect(test_table.where(fk_convert_to_bigint: 0).pluck(:id)).to contain_exactly(15, 19) @@ -71,21 +58,11 @@ RSpec.describe Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJo it 'copies columns with NULLs' do expect(test_table.where("name_convert_to_text = 'no name'").count).to eq(4) - subject.perform(10, 20, table_name, 'id', 'name', 'name_convert_to_text', sub_batch_size) + subject.perform(10, 20, table_name, 'id', sub_batch_size, 'name', 'name_convert_to_text') expect(test_table.where('name = name_convert_to_text').pluck(:id)).to contain_exactly(11, 12, 19) expect(test_table.where('name is NULL and name_convert_to_text is NULL').pluck(:id)).to contain_exactly(15) expect(test_table.where("name_convert_to_text = 'no name'").count).to eq(0) end - - it 'tracks completion with BackgroundMigrationJob' do - expect do - subject.perform(11, 20, table_name, 'id', 'id', 'id_convert_to_bigint', sub_batch_size) - end.to change { Gitlab::Database::BackgroundMigrationJob.succeeded.count }.from(0).to(1) - - expect(job1.reload.status).to eq(0) - expect(job2.reload.status).to eq(1) - expect(test_table.where('id = id_convert_to_bigint').count).to eq(4) - end end end diff --git a/spec/lib/gitlab/background_migration/merge_request_assignees_migration_progress_check_spec.rb b/spec/lib/gitlab/background_migration/merge_request_assignees_migration_progress_check_spec.rb deleted file mode 100644 index 85a9c88ebff..00000000000 --- a/spec/lib/gitlab/background_migration/merge_request_assignees_migration_progress_check_spec.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::BackgroundMigration::MergeRequestAssigneesMigrationProgressCheck do - context 'rescheduling' do - context 'when there are ongoing and no dead jobs' do - it 'reschedules check' do - allow(Gitlab::BackgroundMigration).to receive(:exists?) - .with('PopulateMergeRequestAssigneesTable') - .and_return(true) - - allow(Gitlab::BackgroundMigration).to receive(:dead_jobs?) - .with('PopulateMergeRequestAssigneesTable') - .and_return(false) - - expect(BackgroundMigrationWorker).to receive(:perform_in).with(described_class::RESCHEDULE_DELAY, described_class.name) - - described_class.new.perform - end - end - - context 'when there are ongoing and dead jobs' do - it 'reschedules check' do - allow(Gitlab::BackgroundMigration).to receive(:exists?) - .with('PopulateMergeRequestAssigneesTable') - .and_return(true) - - allow(Gitlab::BackgroundMigration).to receive(:dead_jobs?) - .with('PopulateMergeRequestAssigneesTable') - .and_return(true) - - expect(BackgroundMigrationWorker).to receive(:perform_in).with(described_class::RESCHEDULE_DELAY, described_class.name) - - described_class.new.perform - end - end - - context 'when there retrying jobs and no scheduled' do - it 'reschedules check' do - allow(Gitlab::BackgroundMigration).to receive(:exists?) - .with('PopulateMergeRequestAssigneesTable') - .and_return(false) - - allow(Gitlab::BackgroundMigration).to receive(:retrying_jobs?) - .with('PopulateMergeRequestAssigneesTable') - .and_return(true) - - expect(BackgroundMigrationWorker).to receive(:perform_in).with(described_class::RESCHEDULE_DELAY, described_class.name) - - described_class.new.perform - end - end - end - - context 'when there are no scheduled, or retrying or dead' do - before do - stub_feature_flags(multiple_merge_request_assignees: false) - end - - it 'enables feature' do - allow(Gitlab::BackgroundMigration).to receive(:exists?) - .with('PopulateMergeRequestAssigneesTable') - .and_return(false) - - allow(Gitlab::BackgroundMigration).to receive(:retrying_jobs?) - .with('PopulateMergeRequestAssigneesTable') - .and_return(false) - - allow(Gitlab::BackgroundMigration).to receive(:dead_jobs?) - .with('PopulateMergeRequestAssigneesTable') - .and_return(false) - - described_class.new.perform - - expect(Feature.enabled?(:multiple_merge_request_assignees, type: :licensed)).to eq(true) - end - end - - context 'when there are only dead jobs' do - it 'raises DeadJobsError error' do - allow(Gitlab::BackgroundMigration).to receive(:exists?) - .with('PopulateMergeRequestAssigneesTable') - .and_return(false) - - allow(Gitlab::BackgroundMigration).to receive(:retrying_jobs?) - .with('PopulateMergeRequestAssigneesTable') - .and_return(false) - - allow(Gitlab::BackgroundMigration).to receive(:dead_jobs?) - .with('PopulateMergeRequestAssigneesTable') - .and_return(true) - - expect { described_class.new.perform } - .to raise_error(described_class::DeadJobsError, - "Only dead background jobs in the queue for #{described_class::WORKER}") - end - end -end diff --git a/spec/lib/gitlab/background_migration/migrate_legacy_artifacts_spec.rb b/spec/lib/gitlab/background_migration/migrate_legacy_artifacts_spec.rb index 08f2b2a043e..5c93e69b5e5 100644 --- a/spec/lib/gitlab/background_migration/migrate_legacy_artifacts_spec.rb +++ b/spec/lib/gitlab/background_migration/migrate_legacy_artifacts_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::MigrateLegacyArtifacts do +RSpec.describe Gitlab::BackgroundMigration::MigrateLegacyArtifacts, schema: 20210210093901 do let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } let(:pipelines) { table(:ci_pipelines) } diff --git a/spec/lib/gitlab/background_migration/move_container_registry_enabled_to_project_feature_spec.rb b/spec/lib/gitlab/background_migration/move_container_registry_enabled_to_project_feature_spec.rb new file mode 100644 index 00000000000..1c62d703a34 --- /dev/null +++ b/spec/lib/gitlab/background_migration/move_container_registry_enabled_to_project_feature_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::MoveContainerRegistryEnabledToProjectFeature, :migration, schema: 2021_02_26_120851 do + let(:enabled) { 20 } + let(:disabled) { 0 } + + let(:namespaces) { table(:namespaces) } + let(:project_features) { table(:project_features) } + let(:projects) { table(:projects) } + + let(:namespace) { namespaces.create!(name: 'user', path: 'user') } + let!(:project1) { projects.create!(namespace_id: namespace.id) } + let!(:project2) { projects.create!(namespace_id: namespace.id) } + let!(:project3) { projects.create!(namespace_id: namespace.id) } + let!(:project4) { projects.create!(namespace_id: namespace.id) } + + # pages_access_level cannot be null. + let(:non_null_project_features) { { pages_access_level: enabled } } + let!(:project_feature1) { project_features.create!(project_id: project1.id, **non_null_project_features) } + let!(:project_feature2) { project_features.create!(project_id: project2.id, **non_null_project_features) } + let!(:project_feature3) { project_features.create!(project_id: project3.id, **non_null_project_features) } + + describe '#perform' do + before do + project1.update!(container_registry_enabled: true) + project2.update!(container_registry_enabled: false) + project3.update!(container_registry_enabled: nil) + project4.update!(container_registry_enabled: true) + end + + it 'copies values to project_features' do + expect(project1.container_registry_enabled).to eq(true) + expect(project2.container_registry_enabled).to eq(false) + expect(project3.container_registry_enabled).to eq(nil) + expect(project4.container_registry_enabled).to eq(true) + + expect(project_feature1.container_registry_access_level).to eq(disabled) + expect(project_feature2.container_registry_access_level).to eq(disabled) + expect(project_feature3.container_registry_access_level).to eq(disabled) + + expect_next_instance_of(Gitlab::BackgroundMigration::Logger) do |logger| + expect(logger).to receive(:info) + .with(message: "#{described_class}: Copied container_registry_enabled values for projects with IDs between #{project1.id}..#{project4.id}") + + expect(logger).not_to receive(:info) + end + + subject.perform(project1.id, project4.id) + + expect(project1.reload.container_registry_enabled).to eq(true) + expect(project2.reload.container_registry_enabled).to eq(false) + expect(project3.reload.container_registry_enabled).to eq(nil) + expect(project4.container_registry_enabled).to eq(true) + + expect(project_feature1.reload.container_registry_access_level).to eq(enabled) + expect(project_feature2.reload.container_registry_access_level).to eq(disabled) + expect(project_feature3.reload.container_registry_access_level).to eq(disabled) + end + + context 'when no projects exist in range' do + it 'does not fail' do + expect(project1.container_registry_enabled).to eq(true) + expect(project_feature1.container_registry_access_level).to eq(disabled) + + expect { subject.perform(-1, -2) }.not_to raise_error + + expect(project1.container_registry_enabled).to eq(true) + expect(project_feature1.container_registry_access_level).to eq(disabled) + end + end + + context 'when projects in range all have nil container_registry_enabled' do + it 'does not fail' do + expect(project3.container_registry_enabled).to eq(nil) + expect(project_feature3.container_registry_access_level).to eq(disabled) + + expect { subject.perform(project3.id, project3.id) }.not_to raise_error + + expect(project3.container_registry_enabled).to eq(nil) + expect(project_feature3.container_registry_access_level).to eq(disabled) + end + end + end +end diff --git a/spec/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback_spec.rb b/spec/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback_spec.rb index 8e74935e127..07b1d99d333 100644 --- a/spec/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback_spec.rb +++ b/spec/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback_spec.rb @@ -27,12 +27,33 @@ RSpec.describe Gitlab::BackgroundMigration::PopulateFindingUuidForVulnerabilityF let(:finding_1) { finding_creator.call(sast_report, location_fingerprint_1) } let(:finding_2) { finding_creator.call(dast_report, location_fingerprint_2) } let(:finding_3) { finding_creator.call(secret_detection_report, location_fingerprint_3) } - let(:uuid_1_components) { ['sast', identifier.fingerprint, location_fingerprint_1, project.id].join('-') } - let(:uuid_2_components) { ['dast', identifier.fingerprint, location_fingerprint_2, project.id].join('-') } - let(:uuid_3_components) { ['secret_detection', identifier.fingerprint, location_fingerprint_3, project.id].join('-') } - let(:expected_uuid_1) { Gitlab::UUID.v5(uuid_1_components) } - let(:expected_uuid_2) { Gitlab::UUID.v5(uuid_2_components) } - let(:expected_uuid_3) { Gitlab::UUID.v5(uuid_3_components) } + let(:expected_uuid_1) do + Security::VulnerabilityUUID.generate( + report_type: 'sast', + primary_identifier_fingerprint: identifier.fingerprint, + location_fingerprint: location_fingerprint_1, + project_id: project.id + ) + end + + let(:expected_uuid_2) do + Security::VulnerabilityUUID.generate( + report_type: 'dast', + primary_identifier_fingerprint: identifier.fingerprint, + location_fingerprint: location_fingerprint_2, + project_id: project.id + ) + end + + let(:expected_uuid_3) do + Security::VulnerabilityUUID.generate( + report_type: 'secret_detection', + primary_identifier_fingerprint: identifier.fingerprint, + location_fingerprint: location_fingerprint_3, + project_id: project.id + ) + end + let(:finding_creator) do -> (report_type, location_fingerprint) do findings.create!( diff --git a/spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb b/spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb new file mode 100644 index 00000000000..990ef4fbe6a --- /dev/null +++ b/spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::RecalculateVulnerabilitiesOccurrencesUuid, schema: 20201110110454 do + let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } + let(:users) { table(:users) } + let(:user) { create_user! } + let(:project) { table(:projects).create!(id: 123, namespace_id: namespace.id) } + let(:scanners) { table(:vulnerability_scanners) } + let(:scanner) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') } + let(:different_scanner) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') } + let(:vulnerabilities) { table(:vulnerabilities) } + let(:vulnerabilities_findings) { table(:vulnerability_occurrences) } + let(:vulnerability_identifiers) { table(:vulnerability_identifiers) } + let(:vulnerability_identifier) do + vulnerability_identifiers.create!( + project_id: project.id, + external_type: 'uuid-v5', + external_id: 'uuid-v5', + fingerprint: '7e394d1b1eb461a7406d7b1e08f057a1cf11287a', + name: 'Identifier for UUIDv5') + end + + let(:different_vulnerability_identifier) do + vulnerability_identifiers.create!( + project_id: project.id, + external_type: 'uuid-v4', + external_id: 'uuid-v4', + fingerprint: '772da93d34a1ba010bcb5efa9fb6f8e01bafcc89', + name: 'Identifier for UUIDv4') + end + + let!(:vulnerability_for_uuidv4) do + create_vulnerability!( + project_id: project.id, + author_id: user.id + ) + end + + let!(:vulnerability_for_uuidv5) do + create_vulnerability!( + project_id: project.id, + author_id: user.id + ) + end + + let(:known_uuid_v5) { "77211ed6-7dff-5f6b-8c9a-da89ad0a9b60" } + let(:known_uuid_v4) { "b3cc2518-5446-4dea-871c-89d5e999c1ac" } + let(:desired_uuid_v5) { "3ca8ad45-6344-508b-b5e3-306a3bd6c6ba" } + + subject { described_class.new.perform(finding.id, finding.id) } + + context "when finding has a UUIDv4" do + before do + @uuid_v4 = create_finding!( + vulnerability_id: vulnerability_for_uuidv4.id, + project_id: project.id, + scanner_id: different_scanner.id, + primary_identifier_id: different_vulnerability_identifier.id, + report_type: 0, # "sast" + location_fingerprint: "fa18f432f1d56675f4098d318739c3cd5b14eb3e", + uuid: known_uuid_v4 + ) + end + + let(:finding) { @uuid_v4 } + + it "replaces it with UUIDv5" do + expect(vulnerabilities_findings.pluck(:uuid)).to eq([known_uuid_v4]) + + subject + + expect(vulnerabilities_findings.pluck(:uuid)).to eq([desired_uuid_v5]) + end + end + + context "when finding has a UUIDv5" do + before do + @uuid_v5 = create_finding!( + vulnerability_id: vulnerability_for_uuidv5.id, + project_id: project.id, + scanner_id: scanner.id, + primary_identifier_id: vulnerability_identifier.id, + report_type: 0, # "sast" + location_fingerprint: "838574be0210968bf6b9f569df9c2576242cbf0a", + uuid: known_uuid_v5 + ) + end + + let(:finding) { @uuid_v5 } + + it "stays the same" do + expect(vulnerabilities_findings.pluck(:uuid)).to eq([known_uuid_v5]) + + subject + + expect(vulnerabilities_findings.pluck(:uuid)).to eq([known_uuid_v5]) + end + end + + private + + def create_vulnerability!(project_id:, author_id:, title: 'test', severity: 7, confidence: 7, report_type: 0) + vulnerabilities.create!( + project_id: project_id, + author_id: author_id, + title: title, + severity: severity, + confidence: confidence, + report_type: report_type + ) + end + + # rubocop:disable Metrics/ParameterLists + def create_finding!( + vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:, + name: "test", severity: 7, confidence: 7, report_type: 0, + project_fingerprint: '123qweasdzxc', location_fingerprint: 'test', + metadata_version: 'test', raw_metadata: 'test', uuid: 'test') + vulnerabilities_findings.create!( + vulnerability_id: vulnerability_id, + project_id: project_id, + name: name, + severity: severity, + confidence: confidence, + report_type: report_type, + project_fingerprint: project_fingerprint, + scanner_id: scanner.id, + primary_identifier_id: vulnerability_identifier.id, + location_fingerprint: location_fingerprint, + metadata_version: metadata_version, + raw_metadata: raw_metadata, + uuid: uuid + ) + end + # rubocop:enable Metrics/ParameterLists + + def create_user!(name: "Example User", email: "user@example.com", user_type: nil, created_at: Time.zone.now, confirmed_at: Time.zone.now) + users.create!( + name: name, + email: email, + username: name, + projects_limit: 0, + user_type: user_type, + confirmed_at: confirmed_at + ) + end +end diff --git a/spec/lib/gitlab/background_migration/set_default_iteration_cadences_spec.rb b/spec/lib/gitlab/background_migration/set_default_iteration_cadences_spec.rb new file mode 100644 index 00000000000..46c919f0854 --- /dev/null +++ b/spec/lib/gitlab/background_migration/set_default_iteration_cadences_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::SetDefaultIterationCadences, schema: 20201231133921 do + let(:namespaces) { table(:namespaces) } + let(:iterations) { table(:sprints) } + let(:iterations_cadences) { table(:iterations_cadences) } + + describe '#perform' do + context 'when no iteration cadences exists' do + let!(:group_1) { namespaces.create!(name: 'group 1', path: 'group-1') } + let!(:group_2) { namespaces.create!(name: 'group 2', path: 'group-2') } + let!(:group_3) { namespaces.create!(name: 'group 3', path: 'group-3') } + + let!(:iteration_1) { iterations.create!(group_id: group_1.id, iid: 1, title: 'Iteration 1', start_date: 10.days.ago, due_date: 8.days.ago) } + let!(:iteration_2) { iterations.create!(group_id: group_3.id, iid: 1, title: 'Iteration 2', start_date: 10.days.ago, due_date: 8.days.ago) } + let!(:iteration_3) { iterations.create!(group_id: group_3.id, iid: 1, title: 'Iteration 3', start_date: 5.days.ago, due_date: 2.days.ago) } + + subject { described_class.new.perform(group_1.id, group_2.id, group_3.id, namespaces.last.id + 1) } + + before do + subject + end + + it 'creates iterations_cadence records for the requested groups' do + expect(iterations_cadences.count).to eq(2) + end + + it 'assigns the iteration cadences to the iterations correctly' do + iterations_cadence = iterations_cadences.find_by(group_id: group_1.id) + iteration_records = iterations.where(iterations_cadence_id: iterations_cadence.id) + + expect(iterations_cadence.start_date).to eq(iteration_1.start_date) + expect(iterations_cadence.last_run_date).to eq(iteration_1.start_date) + expect(iterations_cadence.title).to eq('group 1 Iterations') + expect(iteration_records.size).to eq(1) + expect(iteration_records.first.id).to eq(iteration_1.id) + + iterations_cadence = iterations_cadences.find_by(group_id: group_3.id) + iteration_records = iterations.where(iterations_cadence_id: iterations_cadence.id) + + expect(iterations_cadence.start_date).to eq(iteration_3.start_date) + expect(iterations_cadence.last_run_date).to eq(iteration_3.start_date) + expect(iterations_cadence.title).to eq('group 3 Iterations') + expect(iteration_records.size).to eq(2) + expect(iteration_records.first.id).to eq(iteration_2.id) + expect(iteration_records.second.id).to eq(iteration_3.id) + end + + it 'does not call Group class' do + expect(::Group).not_to receive(:where) + + subject + end + end + + context 'when an iteration cadence exists for a group' do + let!(:group) { namespaces.create!(name: 'group', path: 'group') } + + let!(:iterations_cadence_1) { iterations_cadences.create!(group_id: group.id, start_date: 2.days.ago, title: 'Cadence 1') } + + let!(:iteration_1) { iterations.create!(group_id: group.id, iid: 1, title: 'Iteration 1', start_date: 10.days.ago, due_date: 8.days.ago) } + let!(:iteration_2) { iterations.create!(group_id: group.id, iterations_cadence_id: iterations_cadence_1.id, iid: 2, title: 'Iteration 2', start_date: 5.days.ago, due_date: 3.days.ago) } + + subject { described_class.new.perform(group.id) } + + it 'does not create a new iterations_cadence' do + expect { subject }.not_to change { iterations_cadences.count } + end + + it 'assigns iteration cadences to iterations if needed' do + subject + + expect(iteration_1.reload.iterations_cadence_id).to eq(iterations_cadence_1.id) + expect(iteration_2.reload.iterations_cadence_id).to eq(iterations_cadence_1.id) + end + end + end +end diff --git a/spec/lib/gitlab/checks/branch_check_spec.rb b/spec/lib/gitlab/checks/branch_check_spec.rb index 822bdc8389d..3086cb1bd33 100644 --- a/spec/lib/gitlab/checks/branch_check_spec.rb +++ b/spec/lib/gitlab/checks/branch_check_spec.rb @@ -70,6 +70,82 @@ RSpec.describe Gitlab::Checks::BranchCheck do expect { subject.validate! }.to raise_error(Gitlab::GitAccess::ForbiddenError, 'You are not allowed to push code to protected branches on this project.') end + context 'when user has push access' do + before do + allow(user_access) + .to receive(:can_push_to_branch?) + .and_return(true) + end + + context 'if protected branches is allowed to force push' do + before do + allow(ProtectedBranch) + .to receive(:allow_force_push?) + .with(project, 'master') + .and_return(true) + end + + it 'allows force push' do + expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true) + + expect { subject.validate! }.not_to raise_error + end + end + + context 'if protected branches is not allowed to force push' do + before do + allow(ProtectedBranch) + .to receive(:allow_force_push?) + .with(project, 'master') + .and_return(false) + end + + it 'prevents force push' do + expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true) + + expect { subject.validate! }.to raise_error + end + end + end + + context 'when user does not have push access' do + before do + allow(user_access) + .to receive(:can_push_to_branch?) + .and_return(false) + end + + context 'if protected branches is allowed to force push' do + before do + allow(ProtectedBranch) + .to receive(:allow_force_push?) + .with(project, 'master') + .and_return(true) + end + + it 'prevents force push' do + expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true) + + expect { subject.validate! }.to raise_error + end + end + + context 'if protected branches is not allowed to force push' do + before do + allow(ProtectedBranch) + .to receive(:allow_force_push?) + .with(project, 'master') + .and_return(false) + end + + it 'prevents force push' do + expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true) + + expect { subject.validate! }.to raise_error + end + end + end + context 'when project repository is empty' do let(:project) { create(:project) } diff --git a/spec/lib/gitlab/checks/lfs_check_spec.rb b/spec/lib/gitlab/checks/lfs_check_spec.rb index 713858e0e35..19c1d820dff 100644 --- a/spec/lib/gitlab/checks/lfs_check_spec.rb +++ b/spec/lib/gitlab/checks/lfs_check_spec.rb @@ -39,13 +39,26 @@ RSpec.describe Gitlab::Checks::LfsCheck do end end - context 'deletion' do - let(:changes) { { oldrev: oldrev, ref: ref } } + context 'with deletion' do + shared_examples 'a skipped integrity check' do + it 'skips integrity check' do + expect(project.repository).not_to receive(:new_objects) + expect_any_instance_of(Gitlab::Git::LfsChanges).not_to receive(:new_pointers) + + subject.validate! + end + end - it 'skips integrity check' do - expect(project.repository).not_to receive(:new_objects) + context 'with missing newrev' do + it_behaves_like 'a skipped integrity check' do + let(:changes) { { oldrev: oldrev, ref: ref } } + end + end - subject.validate! + context 'with blank newrev' do + it_behaves_like 'a skipped integrity check' do + let(:changes) { { oldrev: oldrev, newrev: Gitlab::Git::BLANK_SHA, ref: ref } } + end end end diff --git a/spec/lib/gitlab/ci/artifacts/metrics_spec.rb b/spec/lib/gitlab/ci/artifacts/metrics_spec.rb new file mode 100644 index 00000000000..3a2095498ec --- /dev/null +++ b/spec/lib/gitlab/ci/artifacts/metrics_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Artifacts::Metrics, :prometheus do + let(:metrics) { described_class.new } + + describe '#increment_destroyed_artifacts' do + context 'when incrementing by more than one' do + let(:counter) { metrics.send(:destroyed_artifacts_counter) } + + it 'increments a single counter' do + subject.increment_destroyed_artifacts(10) + subject.increment_destroyed_artifacts(20) + subject.increment_destroyed_artifacts(30) + + expect(counter.get).to eq 60 + expect(counter.values.count).to eq 1 + end + end + end +end diff --git a/spec/lib/gitlab/ci/build/cache_spec.rb b/spec/lib/gitlab/ci/build/cache_spec.rb new file mode 100644 index 00000000000..9188045988b --- /dev/null +++ b/spec/lib/gitlab/ci/build/cache_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Build::Cache do + describe '.initialize' do + context 'when the multiple cache feature flag is disabled' do + before do + stub_feature_flags(multiple_cache_per_job: false) + end + + it 'instantiates a cache seed' do + cache_config = { key: 'key-a' } + pipeline = double(::Ci::Pipeline) + cache_seed = double(Gitlab::Ci::Pipeline::Seed::Build::Cache) + allow(Gitlab::Ci::Pipeline::Seed::Build::Cache).to receive(:new).and_return(cache_seed) + + cache = described_class.new(cache_config, pipeline) + + expect(Gitlab::Ci::Pipeline::Seed::Build::Cache).to have_received(:new).with(pipeline, cache_config) + expect(cache.instance_variable_get(:@cache)).to eq(cache_seed) + end + end + + context 'when the multiple cache feature flag is enabled' do + context 'when the cache is an array' do + it 'instantiates an array of cache seeds' do + cache_config = [{ key: 'key-a' }, { key: 'key-b' }] + pipeline = double(::Ci::Pipeline) + cache_seed_a = double(Gitlab::Ci::Pipeline::Seed::Build::Cache) + cache_seed_b = double(Gitlab::Ci::Pipeline::Seed::Build::Cache) + allow(Gitlab::Ci::Pipeline::Seed::Build::Cache).to receive(:new).and_return(cache_seed_a, cache_seed_b) + + cache = described_class.new(cache_config, pipeline) + + expect(Gitlab::Ci::Pipeline::Seed::Build::Cache).to have_received(:new).with(pipeline, { key: 'key-a' }) + expect(Gitlab::Ci::Pipeline::Seed::Build::Cache).to have_received(:new).with(pipeline, { key: 'key-b' }) + expect(cache.instance_variable_get(:@cache)).to eq([cache_seed_a, cache_seed_b]) + end + end + + context 'when the cache is a hash' do + it 'instantiates a cache seed' do + cache_config = { key: 'key-a' } + pipeline = double(::Ci::Pipeline) + cache_seed = double(Gitlab::Ci::Pipeline::Seed::Build::Cache) + allow(Gitlab::Ci::Pipeline::Seed::Build::Cache).to receive(:new).and_return(cache_seed) + + cache = described_class.new(cache_config, pipeline) + + expect(Gitlab::Ci::Pipeline::Seed::Build::Cache).to have_received(:new).with(pipeline, cache_config) + expect(cache.instance_variable_get(:@cache)).to eq([cache_seed]) + end + end + end + end + + describe '#cache_attributes' do + context 'when the multiple cache feature flag is disabled' do + before do + stub_feature_flags(multiple_cache_per_job: false) + end + + it "returns the cache seed's build attributes" do + cache_config = { key: 'key-a' } + pipeline = double(::Ci::Pipeline) + cache = described_class.new(cache_config, pipeline) + + attributes = cache.cache_attributes + + expect(attributes).to eq({ + options: { cache: { key: 'key-a' } } + }) + end + end + + context 'when the multiple cache feature flag is enabled' do + context 'when there are no caches' do + it 'returns an empty hash' do + cache_config = [] + pipeline = double(::Ci::Pipeline) + cache = described_class.new(cache_config, pipeline) + + attributes = cache.cache_attributes + + expect(attributes).to eq({}) + end + end + + context 'when there are caches' do + it 'returns the structured attributes for the caches' do + cache_config = [{ key: 'key-a' }, { key: 'key-b' }] + pipeline = double(::Ci::Pipeline) + cache = described_class.new(cache_config, pipeline) + + attributes = cache.cache_attributes + + expect(attributes).to eq({ + options: { cache: cache_config } + }) + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/build/context/build_spec.rb b/spec/lib/gitlab/ci/build/context/build_spec.rb index 61ca8e759b5..46447231424 100644 --- a/spec/lib/gitlab/ci/build/context/build_spec.rb +++ b/spec/lib/gitlab/ci/build/context/build_spec.rb @@ -9,7 +9,9 @@ RSpec.describe Gitlab::Ci::Build::Context::Build do let(:context) { described_class.new(pipeline, seed_attributes) } describe '#variables' do - subject { context.variables } + subject { context.variables.to_hash } + + it { expect(context.variables).to be_instance_of(Gitlab::Ci::Variables::Collection) } it { is_expected.to include('CI_COMMIT_REF_NAME' => 'master') } it { is_expected.to include('CI_PIPELINE_IID' => pipeline.iid.to_s) } diff --git a/spec/lib/gitlab/ci/build/context/global_spec.rb b/spec/lib/gitlab/ci/build/context/global_spec.rb index 7394708f9b6..61f2b90426d 100644 --- a/spec/lib/gitlab/ci/build/context/global_spec.rb +++ b/spec/lib/gitlab/ci/build/context/global_spec.rb @@ -9,7 +9,9 @@ RSpec.describe Gitlab::Ci::Build::Context::Global do let(:context) { described_class.new(pipeline, yaml_variables: yaml_variables) } describe '#variables' do - subject { context.variables } + subject { context.variables.to_hash } + + it { expect(context.variables).to be_instance_of(Gitlab::Ci::Variables::Collection) } it { is_expected.to include('CI_COMMIT_REF_NAME' => 'master') } it { is_expected.to include('CI_PIPELINE_IID' => pipeline.iid.to_s) } diff --git a/spec/lib/gitlab/ci/build/policy/variables_spec.rb b/spec/lib/gitlab/ci/build/policy/variables_spec.rb index f692aa6146e..6c8c968dc0c 100644 --- a/spec/lib/gitlab/ci/build/policy/variables_spec.rb +++ b/spec/lib/gitlab/ci/build/policy/variables_spec.rb @@ -16,7 +16,7 @@ RSpec.describe Gitlab::Ci::Build::Policy::Variables do let(:seed) do double('build seed', to_resource: ci_build, - variables: ci_build.scoped_variables_hash + variables: ci_build.scoped_variables ) end @@ -91,7 +91,7 @@ RSpec.describe Gitlab::Ci::Build::Policy::Variables do let(:seed) do double('bridge seed', to_resource: bridge, - variables: ci_build.scoped_variables_hash + variables: ci_build.scoped_variables ) end diff --git a/spec/lib/gitlab/ci/build/rules/rule_spec.rb b/spec/lib/gitlab/ci/build/rules/rule_spec.rb index 5694cd5d0a0..6f3c9278677 100644 --- a/spec/lib/gitlab/ci/build/rules/rule_spec.rb +++ b/spec/lib/gitlab/ci/build/rules/rule_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Gitlab::Ci::Build::Rules::Rule do let(:seed) do double('build seed', to_resource: ci_build, - variables: ci_build.scoped_variables_hash + variables: ci_build.scoped_variables ) end diff --git a/spec/lib/gitlab/ci/build/rules_spec.rb b/spec/lib/gitlab/ci/build/rules_spec.rb index 0b50def05d4..1d5bdf30278 100644 --- a/spec/lib/gitlab/ci/build/rules_spec.rb +++ b/spec/lib/gitlab/ci/build/rules_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Gitlab::Ci::Build::Rules do let(:seed) do double('build seed', to_resource: ci_build, - variables: ci_build.scoped_variables_hash + variables: ci_build.scoped_variables ) end diff --git a/spec/lib/gitlab/ci/charts_spec.rb b/spec/lib/gitlab/ci/charts_spec.rb index 46d7d4a58f0..3a82d058819 100644 --- a/spec/lib/gitlab/ci/charts_spec.rb +++ b/spec/lib/gitlab/ci/charts_spec.rb @@ -98,7 +98,12 @@ RSpec.describe Gitlab::Ci::Charts do subject { chart.total } before do - create(:ci_empty_pipeline, project: project, duration: 120) + # The created_at time used by the following execution + # can end up being after the creation of the 'today' time + # objects created above, and cause the queried counts to + # go to zero when the test executes close to midnight on the + # CI system, so we explicitly set it to a day earlier + create(:ci_empty_pipeline, project: project, duration: 120, created_at: today - 1.day) end it 'uses a utc time zone for range times' do diff --git a/spec/lib/gitlab/ci/config/entry/bridge_spec.rb b/spec/lib/gitlab/ci/config/entry/bridge_spec.rb index b3b7901074a..179578fe0a8 100644 --- a/spec/lib/gitlab/ci/config/entry/bridge_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/bridge_spec.rb @@ -244,6 +244,52 @@ RSpec.describe Gitlab::Ci::Config::Entry::Bridge do end end end + + context 'when bridge config contains parallel' do + let(:config) { { trigger: 'some/project', parallel: parallel_config } } + + context 'when parallel config is a number' do + let(:parallel_config) { 2 } + + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + it 'returns an error message' do + expect(subject.errors) + .to include(/cannot use "parallel: <number>"/) + end + end + end + + context 'when parallel config is a matrix' do + let(:parallel_config) do + { matrix: [{ PROVIDER: 'aws', STACK: %w[monitoring app1] }, + { PROVIDER: 'gcp', STACK: %w[data] }] } + end + + describe '#valid?' do + it { is_expected.to be_valid } + end + + describe '#value' do + it 'is returns a bridge job configuration' do + expect(subject.value).to eq( + name: :my_bridge, + trigger: { project: 'some/project' }, + ignore: false, + stage: 'test', + only: { refs: %w[branches tags] }, + parallel: { matrix: [{ 'PROVIDER' => ['aws'], 'STACK' => %w(monitoring app1) }, + { 'PROVIDER' => ['gcp'], 'STACK' => %w(data) }] }, + variables: {}, + scheduling_type: :stage + ) + end + end + end + end end describe '#manual_action?' do diff --git a/spec/lib/gitlab/ci/config/entry/cache_spec.rb b/spec/lib/gitlab/ci/config/entry/cache_spec.rb index 247f4b63910..064990667d5 100644 --- a/spec/lib/gitlab/ci/config/entry/cache_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/cache_spec.rb @@ -7,225 +7,285 @@ RSpec.describe Gitlab::Ci::Config::Entry::Cache do subject(:entry) { described_class.new(config) } - describe 'validations' do + context 'with multiple caches' do before do entry.compose! end - context 'when entry config value is correct' do - let(:policy) { nil } - let(:key) { 'some key' } - let(:when_config) { nil } - - let(:config) do - { - key: key, - untracked: true, - paths: ['some/path/'] - }.tap do |config| - config[:policy] = policy if policy - config[:when] = when_config if when_config + describe '#valid?' do + context 'when configuration is valid with a single cache' do + let(:config) { { key: 'key', paths: ["logs/"], untracked: true } } + + it 'is valid' do + expect(entry).to be_valid end end - describe '#value' do - shared_examples 'hash key value' do - it 'returns hash value' do - expect(entry.value).to eq(key: key, untracked: true, paths: ['some/path/'], policy: 'pull-push', when: 'on_success') - end + context 'when configuration is valid with multiple caches' do + let(:config) do + [ + { key: 'key', paths: ["logs/"], untracked: true }, + { key: 'key2', paths: ["logs/"], untracked: true }, + { key: 'key3', paths: ["logs/"], untracked: true } + ] end - it_behaves_like 'hash key value' + it 'is valid' do + expect(entry).to be_valid + end + end - context 'with files' do - let(:key) { { files: %w[a-file other-file] } } + context 'when configuration is not a Hash or Array' do + let(:config) { 'invalid' } - it_behaves_like 'hash key value' + it 'is invalid' do + expect(entry).not_to be_valid end + end - context 'with files and prefix' do - let(:key) { { files: %w[a-file other-file], prefix: 'prefix-value' } } + context 'when entry values contain more than four caches' do + let(:config) do + [ + { key: 'key', paths: ["logs/"], untracked: true }, + { key: 'key2', paths: ["logs/"], untracked: true }, + { key: 'key3', paths: ["logs/"], untracked: true }, + { key: 'key4', paths: ["logs/"], untracked: true }, + { key: 'key5', paths: ["logs/"], untracked: true } + ] + end - it_behaves_like 'hash key value' + it 'is invalid' do + expect(entry.errors).to eq(["caches config no more than 4 caches can be created"]) + expect(entry).not_to be_valid end + end + end + end + + context 'with a single cache' do + before do + stub_feature_flags(multiple_cache_per_job: false) + end + describe 'validations' do + before do + entry.compose! + end - context 'with prefix' do - let(:key) { { prefix: 'prefix-value' } } + context 'when entry config value is correct' do + let(:policy) { nil } + let(:key) { 'some key' } + let(:when_config) { nil } - it 'key is nil' do - expect(entry.value).to match(a_hash_including(key: nil)) + let(:config) do + { + key: key, + untracked: true, + paths: ['some/path/'] + }.tap do |config| + config[:policy] = policy if policy + config[:when] = when_config if when_config end end - context 'with `policy`' do - where(:policy, :result) do - 'pull-push' | 'pull-push' - 'push' | 'push' - 'pull' | 'pull' - 'unknown' | 'unknown' # invalid + describe '#value' do + shared_examples 'hash key value' do + it 'returns hash value' do + expect(entry.value).to eq(key: key, untracked: true, paths: ['some/path/'], policy: 'pull-push', when: 'on_success') + end end - with_them do - it { expect(entry.value).to include(policy: result) } + it_behaves_like 'hash key value' + + context 'with files' do + let(:key) { { files: %w[a-file other-file] } } + + it_behaves_like 'hash key value' end - end - context 'without `policy`' do - it 'assigns policy to default' do - expect(entry.value).to include(policy: 'pull-push') + context 'with files and prefix' do + let(:key) { { files: %w[a-file other-file], prefix: 'prefix-value' } } + + it_behaves_like 'hash key value' end - end - context 'with `when`' do - where(:when_config, :result) do - 'on_success' | 'on_success' - 'on_failure' | 'on_failure' - 'always' | 'always' - 'unknown' | 'unknown' # invalid + context 'with prefix' do + let(:key) { { prefix: 'prefix-value' } } + + it 'key is nil' do + expect(entry.value).to match(a_hash_including(key: nil)) + end end - with_them do - it { expect(entry.value).to include(when: result) } + context 'with `policy`' do + where(:policy, :result) do + 'pull-push' | 'pull-push' + 'push' | 'push' + 'pull' | 'pull' + 'unknown' | 'unknown' # invalid + end + + with_them do + it { expect(entry.value).to include(policy: result) } + end end - end - context 'without `when`' do - it 'assigns when to default' do - expect(entry.value).to include(when: 'on_success') + context 'without `policy`' do + it 'assigns policy to default' do + expect(entry.value).to include(policy: 'pull-push') + end end - end - end - describe '#valid?' do - it { is_expected.to be_valid } + context 'with `when`' do + where(:when_config, :result) do + 'on_success' | 'on_success' + 'on_failure' | 'on_failure' + 'always' | 'always' + 'unknown' | 'unknown' # invalid + end - context 'with files' do - let(:key) { { files: %w[a-file other-file] } } + with_them do + it { expect(entry.value).to include(when: result) } + end + end - it { is_expected.to be_valid } + context 'without `when`' do + it 'assigns when to default' do + expect(entry.value).to include(when: 'on_success') + end + end end - end - context 'with `policy`' do - where(:policy, :valid) do - 'pull-push' | true - 'push' | true - 'pull' | true - 'unknown' | false - end + describe '#valid?' do + it { is_expected.to be_valid } + + context 'with files' do + let(:key) { { files: %w[a-file other-file] } } - with_them do - it 'returns expected validity' do - expect(entry.valid?).to eq(valid) + it { is_expected.to be_valid } end end - end - context 'with `when`' do - where(:when_config, :valid) do - 'on_success' | true - 'on_failure' | true - 'always' | true - 'unknown' | false - end + context 'with `policy`' do + where(:policy, :valid) do + 'pull-push' | true + 'push' | true + 'pull' | true + 'unknown' | false + end - with_them do - it 'returns expected validity' do - expect(entry.valid?).to eq(valid) + with_them do + it 'returns expected validity' do + expect(entry.valid?).to eq(valid) + end end end - end - context 'with key missing' do - let(:config) do - { untracked: true, - paths: ['some/path/'] } + context 'with `when`' do + where(:when_config, :valid) do + 'on_success' | true + 'on_failure' | true + 'always' | true + 'unknown' | false + end + + with_them do + it 'returns expected validity' do + expect(entry.valid?).to eq(valid) + end + end end - describe '#value' do - it 'sets key with the default' do - expect(entry.value[:key]) - .to eq(Gitlab::Ci::Config::Entry::Key.default) + context 'with key missing' do + let(:config) do + { untracked: true, + paths: ['some/path/'] } + end + + describe '#value' do + it 'sets key with the default' do + expect(entry.value[:key]) + .to eq(Gitlab::Ci::Config::Entry::Key.default) + end end end end - end - context 'when entry value is not correct' do - describe '#errors' do - subject { entry.errors } + context 'when entry value is not correct' do + describe '#errors' do + subject { entry.errors } - context 'when is not a hash' do - let(:config) { 'ls' } + context 'when is not a hash' do + let(:config) { 'ls' } - it 'reports errors with config value' do - is_expected.to include 'cache config should be a hash' + it 'reports errors with config value' do + is_expected.to include 'cache config should be a hash' + end end - end - context 'when policy is unknown' do - let(:config) { { policy: 'unknown' } } + context 'when policy is unknown' do + let(:config) { { policy: 'unknown' } } - it 'reports error' do - is_expected.to include('cache policy should be pull-push, push, or pull') + it 'reports error' do + is_expected.to include('cache policy should be pull-push, push, or pull') + end end - end - context 'when `when` is unknown' do - let(:config) { { when: 'unknown' } } + context 'when `when` is unknown' do + let(:config) { { when: 'unknown' } } - it 'reports error' do - is_expected.to include('cache when should be on_success, on_failure or always') + it 'reports error' do + is_expected.to include('cache when should be on_success, on_failure or always') + end end - end - context 'when descendants are invalid' do - context 'with invalid keys' do - let(:config) { { key: 1 } } + context 'when descendants are invalid' do + context 'with invalid keys' do + let(:config) { { key: 1 } } - it 'reports error with descendants' do - is_expected.to include 'key should be a hash, a string or a symbol' + it 'reports error with descendants' do + is_expected.to include 'key should be a hash, a string or a symbol' + end end - end - context 'with empty key' do - let(:config) { { key: {} } } + context 'with empty key' do + let(:config) { { key: {} } } - it 'reports error with descendants' do - is_expected.to include 'key config missing required keys: files' + it 'reports error with descendants' do + is_expected.to include 'key config missing required keys: files' + end end - end - context 'with invalid files' do - let(:config) { { key: { files: 'a-file' } } } + context 'with invalid files' do + let(:config) { { key: { files: 'a-file' } } } - it 'reports error with descendants' do - is_expected.to include 'key:files config should be an array of strings' + it 'reports error with descendants' do + is_expected.to include 'key:files config should be an array of strings' + end end - end - context 'with prefix without files' do - let(:config) { { key: { prefix: 'a-prefix' } } } + context 'with prefix without files' do + let(:config) { { key: { prefix: 'a-prefix' } } } - it 'reports error with descendants' do - is_expected.to include 'key config missing required keys: files' + it 'reports error with descendants' do + is_expected.to include 'key config missing required keys: files' + end end - end - context 'when there is an unknown key present' do - let(:config) { { key: { unknown: 'a-file' } } } + context 'when there is an unknown key present' do + let(:config) { { key: { unknown: 'a-file' } } } - it 'reports error with descendants' do - is_expected.to include 'key config contains unknown keys: unknown' + it 'reports error with descendants' do + is_expected.to include 'key config contains unknown keys: unknown' + end end end - end - context 'when there is an unknown key present' do - let(:config) { { invalid: true } } + context 'when there is an unknown key present' do + let(:config) { { invalid: true } } - it 'reports error with descendants' do - is_expected.to include 'cache config contains unknown keys: invalid' + it 'reports error with descendants' do + is_expected.to include 'cache config contains unknown keys: invalid' + end end end end diff --git a/spec/lib/gitlab/ci/config/entry/environment_spec.rb b/spec/lib/gitlab/ci/config/entry/environment_spec.rb index 0c18a7fb71e..dd8a79f0d84 100644 --- a/spec/lib/gitlab/ci/config/entry/environment_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/environment_spec.rb @@ -305,4 +305,37 @@ RSpec.describe Gitlab::Ci::Config::Entry::Environment do it { expect(entry).to be_valid } end end + + describe 'deployment_tier' do + let(:config) do + { name: 'customer-portal', deployment_tier: deployment_tier } + end + + context 'is a string' do + let(:deployment_tier) { 'production' } + + it { expect(entry).to be_valid } + end + + context 'is a hash' do + let(:deployment_tier) { Hash(tier: 'production') } + + it { expect(entry).not_to be_valid } + end + + context 'is nil' do + let(:deployment_tier) { nil } + + it { expect(entry).to be_valid } + end + + context 'is unknown value' do + let(:deployment_tier) { 'unknown' } + + it 'is invalid and adds an error' do + expect(entry).not_to be_valid + expect(entry.errors).to include("environment deployment tier must be one of #{::Environment.tiers.keys.join(', ')}") + end + end + end end diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb index a3b5f32b9f9..a4167003987 100644 --- a/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -537,7 +537,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do it 'overrides default config' do expect(entry[:image].value).to eq(name: 'some_image') - expect(entry[:cache].value).to eq(key: 'test', policy: 'pull-push', when: 'on_success') + expect(entry[:cache].value).to eq([key: 'test', policy: 'pull-push', when: 'on_success']) end end @@ -552,7 +552,43 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do it 'uses config from default entry' do expect(entry[:image].value).to eq 'specified' - expect(entry[:cache].value).to eq(key: 'test', policy: 'pull-push', when: 'on_success') + expect(entry[:cache].value).to eq([key: 'test', policy: 'pull-push', when: 'on_success']) + end + end + + context 'with multiple_cache_per_job FF disabled' do + before do + stub_feature_flags(multiple_cache_per_job: false) + end + + context 'when job config overrides default config' do + before do + entry.compose!(deps) + end + + let(:config) do + { script: 'rspec', image: 'some_image', cache: { key: 'test' } } + end + + it 'overrides default config' do + expect(entry[:image].value).to eq(name: 'some_image') + expect(entry[:cache].value).to eq(key: 'test', policy: 'pull-push', when: 'on_success') + end + end + + context 'when job config does not override default config' do + before do + allow(default).to receive('[]').with(:image).and_return(specified) + + entry.compose!(deps) + end + + let(:config) { { script: 'ls', cache: { key: 'test' } } } + + it 'uses config from default entry' do + expect(entry[:image].value).to eq 'specified' + expect(entry[:cache].value).to eq(key: 'test', policy: 'pull-push', when: 'on_success') + end end end diff --git a/spec/lib/gitlab/ci/config/entry/need_spec.rb b/spec/lib/gitlab/ci/config/entry/need_spec.rb index 983e95fae42..a0a5dd52ad4 100644 --- a/spec/lib/gitlab/ci/config/entry/need_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/need_spec.rb @@ -23,7 +23,17 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Need do describe '#value' do it 'returns job needs configuration' do - expect(need.value).to eq(name: 'job_name', artifacts: true) + expect(need.value).to eq(name: 'job_name', artifacts: true, optional: false) + end + + context 'when the FF ci_needs_optional is disabled' do + before do + stub_feature_flags(ci_needs_optional: false) + end + + it 'returns job needs configuration without `optional`' do + expect(need.value).to eq(name: 'job_name', artifacts: true) + end end end @@ -58,7 +68,7 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Need do describe '#value' do it 'returns job needs configuration' do - expect(need.value).to eq(name: 'job_name', artifacts: true) + expect(need.value).to eq(name: 'job_name', artifacts: true, optional: false) end end @@ -74,7 +84,7 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Need do describe '#value' do it 'returns job needs configuration' do - expect(need.value).to eq(name: 'job_name', artifacts: false) + expect(need.value).to eq(name: 'job_name', artifacts: false, optional: false) end end @@ -90,7 +100,7 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Need do describe '#value' do it 'returns job needs configuration' do - expect(need.value).to eq(name: 'job_name', artifacts: true) + expect(need.value).to eq(name: 'job_name', artifacts: true, optional: false) end end @@ -106,11 +116,77 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Need do describe '#value' do it 'returns job needs configuration' do - expect(need.value).to eq(name: 'job_name', artifacts: true) + expect(need.value).to eq(name: 'job_name', artifacts: true, optional: false) + end + end + + it_behaves_like 'job type' + end + + context 'with job name and optional true' do + let(:config) { { job: 'job_name', optional: true } } + + it { is_expected.to be_valid } + + it_behaves_like 'job type' + + describe '#value' do + it 'returns job needs configuration' do + expect(need.value).to eq(name: 'job_name', artifacts: true, optional: true) + end + + context 'when the FF ci_needs_optional is disabled' do + before do + stub_feature_flags(ci_needs_optional: false) + end + + it 'returns job needs configuration without `optional`' do + expect(need.value).to eq(name: 'job_name', artifacts: true) + end end end + end + + context 'with job name and optional false' do + let(:config) { { job: 'job_name', optional: false } } + + it { is_expected.to be_valid } it_behaves_like 'job type' + + describe '#value' do + it 'returns job needs configuration' do + expect(need.value).to eq(name: 'job_name', artifacts: true, optional: false) + end + end + end + + context 'with job name and optional nil' do + let(:config) { { job: 'job_name', optional: nil } } + + it { is_expected.to be_valid } + + it_behaves_like 'job type' + + describe '#value' do + it 'returns job needs configuration' do + expect(need.value).to eq(name: 'job_name', artifacts: true, optional: false) + end + end + end + + context 'without optional key' do + let(:config) { { job: 'job_name' } } + + it { is_expected.to be_valid } + + it_behaves_like 'job type' + + describe '#value' do + it 'returns job needs configuration' do + expect(need.value).to eq(name: 'job_name', artifacts: true, optional: false) + end + end end context 'when job name is empty' do diff --git a/spec/lib/gitlab/ci/config/entry/needs_spec.rb b/spec/lib/gitlab/ci/config/entry/needs_spec.rb index f11f2a56f5f..489fbac68b2 100644 --- a/spec/lib/gitlab/ci/config/entry/needs_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/needs_spec.rb @@ -111,8 +111,8 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Needs do it 'returns key value' do expect(needs.value).to eq( job: [ - { name: 'first_job_name', artifacts: true }, - { name: 'second_job_name', artifacts: true } + { name: 'first_job_name', artifacts: true, optional: false }, + { name: 'second_job_name', artifacts: true, optional: false } ] ) end @@ -124,8 +124,8 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Needs do context 'with complex job entries composed' do let(:config) do [ - { job: 'first_job_name', artifacts: true }, - { job: 'second_job_name', artifacts: false } + { job: 'first_job_name', artifacts: true, optional: false }, + { job: 'second_job_name', artifacts: false, optional: false } ] end @@ -137,8 +137,8 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Needs do it 'returns key value' do expect(needs.value).to eq( job: [ - { name: 'first_job_name', artifacts: true }, - { name: 'second_job_name', artifacts: false } + { name: 'first_job_name', artifacts: true, optional: false }, + { name: 'second_job_name', artifacts: false, optional: false } ] ) end @@ -163,8 +163,8 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Needs do it 'returns key value' do expect(needs.value).to eq( job: [ - { name: 'first_job_name', artifacts: true }, - { name: 'second_job_name', artifacts: false } + { name: 'first_job_name', artifacts: true, optional: false }, + { name: 'second_job_name', artifacts: false, optional: false } ] ) end diff --git a/spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb b/spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb index bc09e20d748..937642f07e7 100644 --- a/spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb @@ -4,21 +4,23 @@ require 'fast_spec_helper' require_dependency 'active_model' RSpec.describe ::Gitlab::Ci::Config::Entry::Product::Parallel do - subject(:parallel) { described_class.new(config) } + let(:metadata) { {} } - context 'with invalid config' do - shared_examples 'invalid config' do |error_message| - describe '#valid?' do - it { is_expected.not_to be_valid } - end + subject(:parallel) { described_class.new(config, **metadata) } - describe '#errors' do - it 'returns error about invalid type' do - expect(parallel.errors).to match(a_collection_including(error_message)) - end + shared_examples 'invalid config' do |error_message| + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + it 'returns error about invalid type' do + expect(parallel.errors).to match(a_collection_including(error_message)) end end + end + context 'with invalid config' do context 'when it is not a numeric value' do let(:config) { true } @@ -63,6 +65,12 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Product::Parallel do expect(parallel.value).to match(number: config) end end + + context 'when :numeric is not allowed' do + let(:metadata) { { allowed_strategies: [:matrix] } } + + it_behaves_like 'invalid config', /cannot use "parallel: <number>"/ + end end end @@ -89,6 +97,12 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Product::Parallel do ]) end end + + context 'when :matrix is not allowed' do + let(:metadata) { { allowed_strategies: [:numeric] } } + + it_behaves_like 'invalid config', /cannot use "parallel: matrix"/ + end end end end diff --git a/spec/lib/gitlab/ci/config/entry/root_spec.rb b/spec/lib/gitlab/ci/config/entry/root_spec.rb index 54c7a5c3602..7b38c21788f 100644 --- a/spec/lib/gitlab/ci/config/entry/root_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/root_spec.rb @@ -126,49 +126,105 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do expect(root.jobs_value.keys).to eq([:rspec, :spinach, :release]) expect(root.jobs_value[:rspec]).to eq( { name: :rspec, - script: %w[rspec ls], - before_script: %w(ls pwd), - image: { name: 'ruby:2.7' }, - services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], - stage: 'test', - cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }, - variables: { 'VAR' => 'root', 'VAR2' => 'val 2' }, - ignore: false, - after_script: ['make clean'], - only: { refs: %w[branches tags] }, - scheduling_type: :stage } + script: %w[rspec ls], + before_script: %w(ls pwd), + image: { name: 'ruby:2.7' }, + services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], + stage: 'test', + cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }], + variables: { 'VAR' => 'root', 'VAR2' => 'val 2' }, + ignore: false, + after_script: ['make clean'], + only: { refs: %w[branches tags] }, + scheduling_type: :stage } ) expect(root.jobs_value[:spinach]).to eq( { name: :spinach, - before_script: [], - script: %w[spinach], - image: { name: 'ruby:2.7' }, - services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], - stage: 'test', - cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }, - variables: { 'VAR' => 'root', 'VAR2' => 'val 2' }, - ignore: false, - after_script: ['make clean'], - only: { refs: %w[branches tags] }, - scheduling_type: :stage } + before_script: [], + script: %w[spinach], + image: { name: 'ruby:2.7' }, + services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], + stage: 'test', + cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }], + variables: { 'VAR' => 'root', 'VAR2' => 'val 2' }, + ignore: false, + after_script: ['make clean'], + only: { refs: %w[branches tags] }, + scheduling_type: :stage } ) expect(root.jobs_value[:release]).to eq( { name: :release, - stage: 'release', - before_script: [], - script: ["make changelog | tee release_changelog.txt"], - release: { name: "Release $CI_TAG_NAME", tag_name: 'v0.06', description: "./release_changelog.txt" }, - image: { name: "ruby:2.7" }, - services: [{ name: "postgres:9.1" }, { name: "mysql:5.5" }], - cache: { key: "k", untracked: true, paths: ["public/"], policy: "pull-push", when: 'on_success' }, - only: { refs: %w(branches tags) }, - variables: { 'VAR' => 'job', 'VAR2' => 'val 2' }, - after_script: [], - ignore: false, - scheduling_type: :stage } + stage: 'release', + before_script: [], + script: ["make changelog | tee release_changelog.txt"], + release: { name: "Release $CI_TAG_NAME", tag_name: 'v0.06', description: "./release_changelog.txt" }, + image: { name: "ruby:2.7" }, + services: [{ name: "postgres:9.1" }, { name: "mysql:5.5" }], + cache: [{ key: "k", untracked: true, paths: ["public/"], policy: "pull-push", when: 'on_success' }], + only: { refs: %w(branches tags) }, + variables: { 'VAR' => 'job', 'VAR2' => 'val 2' }, + after_script: [], + ignore: false, + scheduling_type: :stage } ) end end + + context 'with multuple_cache_per_job FF disabled' do + before do + stub_feature_flags(multiple_cache_per_job: false) + root.compose! + end + + describe '#jobs_value' do + it 'returns jobs configuration' do + expect(root.jobs_value.keys).to eq([:rspec, :spinach, :release]) + expect(root.jobs_value[:rspec]).to eq( + { name: :rspec, + script: %w[rspec ls], + before_script: %w(ls pwd), + image: { name: 'ruby:2.7' }, + services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], + stage: 'test', + cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }, + variables: { 'VAR' => 'root', 'VAR2' => 'val 2' }, + ignore: false, + after_script: ['make clean'], + only: { refs: %w[branches tags] }, + scheduling_type: :stage } + ) + expect(root.jobs_value[:spinach]).to eq( + { name: :spinach, + before_script: [], + script: %w[spinach], + image: { name: 'ruby:2.7' }, + services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], + stage: 'test', + cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }, + variables: { 'VAR' => 'root', 'VAR2' => 'val 2' }, + ignore: false, + after_script: ['make clean'], + only: { refs: %w[branches tags] }, + scheduling_type: :stage } + ) + expect(root.jobs_value[:release]).to eq( + { name: :release, + stage: 'release', + before_script: [], + script: ["make changelog | tee release_changelog.txt"], + release: { name: "Release $CI_TAG_NAME", tag_name: 'v0.06', description: "./release_changelog.txt" }, + image: { name: "ruby:2.7" }, + services: [{ name: "postgres:9.1" }, { name: "mysql:5.5" }], + cache: { key: "k", untracked: true, paths: ["public/"], policy: "pull-push", when: 'on_success' }, + only: { refs: %w(branches tags) }, + variables: { 'VAR' => 'job', 'VAR2' => 'val 2' }, + after_script: [], + ignore: false, + scheduling_type: :stage } + ) + end + end + end end end @@ -187,6 +243,52 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do spinach: { before_script: [], variables: { VAR: 'job' }, script: 'spinach' } } end + context 'with multiple_cache_per_job FF disabled' do + context 'when composed' do + before do + stub_feature_flags(multiple_cache_per_job: false) + root.compose! + end + + describe '#errors' do + it 'has no errors' do + expect(root.errors).to be_empty + end + end + + describe '#jobs_value' do + it 'returns jobs configuration' do + expect(root.jobs_value).to eq( + rspec: { name: :rspec, + script: %w[rspec ls], + before_script: %w(ls pwd), + image: { name: 'ruby:2.7' }, + services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], + stage: 'test', + cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }, + variables: { 'VAR' => 'root' }, + ignore: false, + after_script: ['make clean'], + only: { refs: %w[branches tags] }, + scheduling_type: :stage }, + spinach: { name: :spinach, + before_script: [], + script: %w[spinach], + image: { name: 'ruby:2.7' }, + services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], + stage: 'test', + cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }, + variables: { 'VAR' => 'job' }, + ignore: false, + after_script: ['make clean'], + only: { refs: %w[branches tags] }, + scheduling_type: :stage } + ) + end + end + end + end + context 'when composed' do before do root.compose! @@ -202,29 +304,29 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do it 'returns jobs configuration' do expect(root.jobs_value).to eq( rspec: { name: :rspec, - script: %w[rspec ls], - before_script: %w(ls pwd), - image: { name: 'ruby:2.7' }, - services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], - stage: 'test', - cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }, - variables: { 'VAR' => 'root' }, - ignore: false, - after_script: ['make clean'], - only: { refs: %w[branches tags] }, - scheduling_type: :stage }, + script: %w[rspec ls], + before_script: %w(ls pwd), + image: { name: 'ruby:2.7' }, + services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], + stage: 'test', + cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }], + variables: { 'VAR' => 'root' }, + ignore: false, + after_script: ['make clean'], + only: { refs: %w[branches tags] }, + scheduling_type: :stage }, spinach: { name: :spinach, - before_script: [], - script: %w[spinach], - image: { name: 'ruby:2.7' }, - services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], - stage: 'test', - cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }, - variables: { 'VAR' => 'job' }, - ignore: false, - after_script: ['make clean'], - only: { refs: %w[branches tags] }, - scheduling_type: :stage } + before_script: [], + script: %w[spinach], + image: { name: 'ruby:2.7' }, + services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], + stage: 'test', + cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }], + variables: { 'VAR' => 'job' }, + ignore: false, + after_script: ['make clean'], + only: { refs: %w[branches tags] }, + scheduling_type: :stage } ) end end @@ -265,7 +367,20 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do describe '#cache_value' do it 'returns correct cache definition' do - expect(root.cache_value).to eq(key: 'a', policy: 'pull-push', when: 'on_success') + expect(root.cache_value).to eq([key: 'a', policy: 'pull-push', when: 'on_success']) + end + end + + context 'with multiple_cache_per_job FF disabled' do + before do + stub_feature_flags(multiple_cache_per_job: false) + root.compose! + end + + describe '#cache_value' do + it 'returns correct cache definition' do + expect(root.cache_value).to eq(key: 'a', policy: 'pull-push', when: 'on_success') + end end end end diff --git a/spec/lib/gitlab/ci/jwt_spec.rb b/spec/lib/gitlab/ci/jwt_spec.rb index 342ca6b8b75..480a4a05379 100644 --- a/spec/lib/gitlab/ci/jwt_spec.rb +++ b/spec/lib/gitlab/ci/jwt_spec.rb @@ -114,17 +114,6 @@ RSpec.describe Gitlab::Ci::Jwt do expect(payload[:environment]).to eq('production') expect(payload[:environment_protected]).to eq('false') end - - context ':ci_jwt_include_environment feature flag is disabled' do - before do - stub_feature_flags(ci_jwt_include_environment: false) - end - - it 'does not include environment attributes' do - expect(payload).not_to have_key(:environment) - expect(payload).not_to have_key(:environment_protected) - end - end end end diff --git a/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb index cf3644c9ad5..ec7eebdc056 100644 --- a/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb @@ -3,17 +3,16 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Pipeline::Expression::Statement do - subject do - described_class.new(text, variables) + let(:variables) do + Gitlab::Ci::Variables::Collection.new + .append(key: 'PRESENT_VARIABLE', value: 'my variable') + .append(key: 'PATH_VARIABLE', value: 'a/path/variable/value') + .append(key: 'FULL_PATH_VARIABLE', value: '/a/full/path/variable/value') + .append(key: 'EMPTY_VARIABLE', value: '') end - let(:variables) do - { - 'PRESENT_VARIABLE' => 'my variable', - 'PATH_VARIABLE' => 'a/path/variable/value', - 'FULL_PATH_VARIABLE' => '/a/full/path/variable/value', - 'EMPTY_VARIABLE' => '' - } + subject do + described_class.new(text, variables) end describe '.new' do diff --git a/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb index 570706bfaac..773cb61b946 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb @@ -9,8 +9,255 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do let(:processor) { described_class.new(pipeline, config) } - describe '#build_attributes' do - subject { processor.build_attributes } + context 'with multiple_cache_per_job ff disabled' do + before do + stub_feature_flags(multiple_cache_per_job: false) + end + + describe '#build_attributes' do + subject { processor.build_attributes } + + context 'with cache:key' do + let(:config) do + { + key: 'a-key', + paths: ['vendor/ruby'] + } + end + + it { is_expected.to include(options: { cache: config }) } + end + + context 'with cache:key as a symbol' do + let(:config) do + { + key: :a_key, + paths: ['vendor/ruby'] + } + end + + it { is_expected.to include(options: { cache: config.merge(key: "a_key") }) } + end + + context 'with cache:key:files' do + shared_examples 'default key' do + let(:config) do + { key: { files: files } } + end + + it 'uses default key' do + expected = { options: { cache: { key: 'default' } } } + + is_expected.to include(expected) + end + end + + shared_examples 'version and gemfile files' do + let(:config) do + { + key: { + files: files + }, + paths: ['vendor/ruby'] + } + end + + it 'builds a string key' do + expected = { + options: { + cache: { + key: '703ecc8fef1635427a1f86a8a1a308831c122392', + paths: ['vendor/ruby'] + } + } + } + + is_expected.to include(expected) + end + end + + context 'with existing files' do + let(:files) { ['VERSION', 'Gemfile.zip'] } + + it_behaves_like 'version and gemfile files' + end + + context 'with files starting with ./' do + let(:files) { ['Gemfile.zip', './VERSION'] } + + it_behaves_like 'version and gemfile files' + end + + context 'with files ending with /' do + let(:files) { ['Gemfile.zip/'] } + + it_behaves_like 'default key' + end + + context 'with new line in filenames' do + let(:files) { ["Gemfile.zip\nVERSION"] } + + it_behaves_like 'default key' + end + + context 'with missing files' do + let(:files) { ['project-gemfile.lock', ''] } + + it_behaves_like 'default key' + end + + context 'with directories' do + shared_examples 'foo/bar directory key' do + let(:config) do + { + key: { + files: files + } + } + end + + it 'builds a string key' do + expected = { + options: { + cache: { key: '74bf43fb1090f161bdd4e265802775dbda2f03d1' } + } + } + + is_expected.to include(expected) + end + end + + context 'with directory' do + let(:files) { ['foo/bar'] } + + it_behaves_like 'foo/bar directory key' + end + + context 'with directory ending in slash' do + let(:files) { ['foo/bar/'] } + + it_behaves_like 'foo/bar directory key' + end + + context 'with directories ending in slash star' do + let(:files) { ['foo/bar/*'] } + + it_behaves_like 'foo/bar directory key' + end + end + end + + context 'with cache:key:prefix' do + context 'without files' do + let(:config) do + { + key: { + prefix: 'a-prefix' + }, + paths: ['vendor/ruby'] + } + end + + it 'adds prefix to default key' do + expected = { + options: { + cache: { + key: 'a-prefix-default', + paths: ['vendor/ruby'] + } + } + } + + is_expected.to include(expected) + end + end + + context 'with existing files' do + let(:config) do + { + key: { + files: ['VERSION', 'Gemfile.zip'], + prefix: 'a-prefix' + }, + paths: ['vendor/ruby'] + } + end + + it 'adds prefix key' do + expected = { + options: { + cache: { + key: 'a-prefix-703ecc8fef1635427a1f86a8a1a308831c122392', + paths: ['vendor/ruby'] + } + } + } + + is_expected.to include(expected) + end + end + + context 'with missing files' do + let(:config) do + { + key: { + files: ['project-gemfile.lock', ''], + prefix: 'a-prefix' + }, + paths: ['vendor/ruby'] + } + end + + it 'adds prefix to default key' do + expected = { + options: { + cache: { + key: 'a-prefix-default', + paths: ['vendor/ruby'] + } + } + } + + is_expected.to include(expected) + end + end + end + + context 'with all cache option keys' do + let(:config) do + { + key: 'a-key', + paths: ['vendor/ruby'], + untracked: true, + policy: 'push', + when: 'on_success' + } + end + + it { is_expected.to include(options: { cache: config }) } + end + + context 'with unknown cache option keys' do + let(:config) do + { + key: 'a-key', + unknown_key: true + } + end + + it { expect { subject }.to raise_error(ArgumentError, /unknown_key/) } + end + + context 'with empty config' do + let(:config) { {} } + + it { is_expected.to include(options: {}) } + end + end + end + + describe '#attributes' do + subject { processor.attributes } context 'with cache:key' do let(:config) do @@ -20,7 +267,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do } end - it { is_expected.to include(options: { cache: config }) } + it { is_expected.to include(config) } end context 'with cache:key as a symbol' do @@ -31,7 +278,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do } end - it { is_expected.to include(options: { cache: config.merge(key: "a_key") }) } + it { is_expected.to include(config.merge(key: "a_key")) } end context 'with cache:key:files' do @@ -41,7 +288,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do end it 'uses default key' do - expected = { options: { cache: { key: 'default' } } } + expected = { key: 'default' } is_expected.to include(expected) end @@ -59,13 +306,9 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do it 'builds a string key' do expected = { - options: { - cache: { key: '703ecc8fef1635427a1f86a8a1a308831c122392', paths: ['vendor/ruby'] - } } - } is_expected.to include(expected) end @@ -112,11 +355,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do end it 'builds a string key' do - expected = { - options: { - cache: { key: '74bf43fb1090f161bdd4e265802775dbda2f03d1' } - } - } + expected = { key: '74bf43fb1090f161bdd4e265802775dbda2f03d1' } is_expected.to include(expected) end @@ -155,13 +394,9 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do it 'adds prefix to default key' do expected = { - options: { - cache: { key: 'a-prefix-default', paths: ['vendor/ruby'] } - } - } is_expected.to include(expected) end @@ -180,13 +415,9 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do it 'adds prefix key' do expected = { - options: { - cache: { key: 'a-prefix-703ecc8fef1635427a1f86a8a1a308831c122392', paths: ['vendor/ruby'] } - } - } is_expected.to include(expected) end @@ -205,13 +436,9 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do it 'adds prefix to default key' do expected = { - options: { - cache: { key: 'a-prefix-default', paths: ['vendor/ruby'] } - } - } is_expected.to include(expected) end @@ -229,7 +456,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do } end - it { is_expected.to include(options: { cache: config }) } + it { is_expected.to include(config) } end context 'with unknown cache option keys' do @@ -242,11 +469,5 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do it { expect { subject }.to raise_error(ArgumentError, /unknown_key/) } end - - context 'with empty config' do - let(:config) { {} } - - it { is_expected.to include(options: {}) } - end end end diff --git a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb index 0efc7484699..7ec6949f852 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb @@ -85,99 +85,169 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do { key: 'VAR2', value: 'var 2', public: true }, { key: 'VAR3', value: 'var 3', public: true }]) end + end - context 'when FF ci_rules_variables is disabled' do - before do - stub_feature_flags(ci_rules_variables: false) - end + context 'with multiple_cache_per_job FF disabled' do + before do + stub_feature_flags(multiple_cache_per_job: false) + end - it do - is_expected.to include(yaml_variables: [{ key: 'VAR1', value: 'var 1', public: true }, - { key: 'VAR2', value: 'var 2', public: true }]) + context 'with cache:key' do + let(:attributes) do + { + name: 'rspec', + ref: 'master', + cache: { + key: 'a-value' + } + } end + + it { is_expected.to include(options: { cache: { key: 'a-value' } }) } end - end - context 'with cache:key' do - let(:attributes) do - { - name: 'rspec', - ref: 'master', - cache: { - key: 'a-value' + context 'with cache:key:files' do + let(:attributes) do + { + name: 'rspec', + ref: 'master', + cache: { + key: { + files: ['VERSION'] + } + } } - } - end + end - it { is_expected.to include(options: { cache: { key: 'a-value' } }) } - end + it 'includes cache options' do + cache_options = { + options: { + cache: { key: 'f155568ad0933d8358f66b846133614f76dd0ca4' } + } + } - context 'with cache:key:files' do - let(:attributes) do - { - name: 'rspec', - ref: 'master', - cache: { - key: { - files: ['VERSION'] + is_expected.to include(cache_options) + end + end + + context 'with cache:key:prefix' do + let(:attributes) do + { + name: 'rspec', + ref: 'master', + cache: { + key: { + prefix: 'something' + } } } - } + end + + it { is_expected.to include(options: { cache: { key: 'something-default' } }) } end - it 'includes cache options' do - cache_options = { - options: { + context 'with cache:key:files and prefix' do + let(:attributes) do + { + name: 'rspec', + ref: 'master', cache: { - key: 'f155568ad0933d8358f66b846133614f76dd0ca4' + key: { + files: ['VERSION'], + prefix: 'something' + } } } - } + end - is_expected.to include(cache_options) + it 'includes cache options' do + cache_options = { + options: { + cache: { key: 'something-f155568ad0933d8358f66b846133614f76dd0ca4' } + } + } + + is_expected.to include(cache_options) + end end end - context 'with cache:key:prefix' do + context 'with cache:key' do let(:attributes) do { name: 'rspec', ref: 'master', - cache: { - key: { - prefix: 'something' - } - } + cache: [{ + key: 'a-value' + }] } end - it { is_expected.to include(options: { cache: { key: 'something-default' } }) } - end + it { is_expected.to include(options: { cache: [a_hash_including(key: 'a-value')] }) } - context 'with cache:key:files and prefix' do - let(:attributes) do - { - name: 'rspec', - ref: 'master', - cache: { - key: { - files: ['VERSION'], - prefix: 'something' + context 'with cache:key:files' do + let(:attributes) do + { + name: 'rspec', + ref: 'master', + cache: [{ + key: { + files: ['VERSION'] + } + }] + } + end + + it 'includes cache options' do + cache_options = { + options: { + cache: [a_hash_including(key: 'f155568ad0933d8358f66b846133614f76dd0ca4')] } } - } + + is_expected.to include(cache_options) + end end - it 'includes cache options' do - cache_options = { - options: { - cache: { - key: 'something-f155568ad0933d8358f66b846133614f76dd0ca4' + context 'with cache:key:prefix' do + let(:attributes) do + { + name: 'rspec', + ref: 'master', + cache: [{ + key: { + prefix: 'something' + } + }] + } + end + + it { is_expected.to include(options: { cache: [a_hash_including( key: 'something-default' )] }) } + end + + context 'with cache:key:files and prefix' do + let(:attributes) do + { + name: 'rspec', + ref: 'master', + cache: [{ + key: { + files: ['VERSION'], + prefix: 'something' + } + }] + } + end + + it 'includes cache options' do + cache_options = { + options: { + cache: [a_hash_including(key: 'something-f155568ad0933d8358f66b846133614f76dd0ca4')] } } - } - is_expected.to include(cache_options) + is_expected.to include(cache_options) + end end end @@ -190,7 +260,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do } end - it { is_expected.to include(options: {}) } + it { is_expected.to include({}) } end context 'with allow_failure' do @@ -307,7 +377,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do it 'does not have environment' do expect(subject).not_to be_has_environment expect(subject.environment).to be_nil - expect(subject.metadata.expanded_environment_name).to be_nil + expect(subject.metadata).to be_nil expect(Environment.exists?(name: expected_environment_name)).to eq(false) end end @@ -979,6 +1049,25 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do expect(subject.errors).to contain_exactly( "'rspec' job needs 'build' job, but it was not added to the pipeline") end + + context 'when the needed job is optional' do + let(:needs_attributes) { [{ name: 'build', optional: true }] } + + it "does not return an error" do + expect(subject.errors).to be_empty + end + + context 'when the FF ci_needs_optional is disabled' do + before do + stub_feature_flags(ci_needs_optional: false) + end + + it "returns an error" do + expect(subject.errors).to contain_exactly( + "'rspec' job needs 'build' job, but it was not added to the pipeline") + end + end + end end context 'when build job is part of prior stages' do @@ -1036,4 +1125,75 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do end end end + + describe 'applying pipeline variables' do + subject { seed_build } + + let(:pipeline_variables) { [] } + let(:pipeline) do + build(:ci_empty_pipeline, project: project, sha: head_sha, variables: pipeline_variables) + end + + context 'containing variable references' do + let(:pipeline_variables) do + [ + build(:ci_pipeline_variable, key: 'A', value: '$B'), + build(:ci_pipeline_variable, key: 'B', value: '$C') + ] + end + + context 'when FF :variable_inside_variable is enabled' do + before do + stub_feature_flags(variable_inside_variable: [project]) + end + + it "does not have errors" do + expect(subject.errors).to be_empty + end + end + end + + context 'containing cyclic reference' do + let(:pipeline_variables) do + [ + build(:ci_pipeline_variable, key: 'A', value: '$B'), + build(:ci_pipeline_variable, key: 'B', value: '$C'), + build(:ci_pipeline_variable, key: 'C', value: '$A') + ] + end + + context 'when FF :variable_inside_variable is disabled' do + before do + stub_feature_flags(variable_inside_variable: false) + end + + it "does not have errors" do + expect(subject.errors).to be_empty + end + end + + context 'when FF :variable_inside_variable is enabled' do + before do + stub_feature_flags(variable_inside_variable: [project]) + end + + it "returns an error" do + expect(subject.errors).to contain_exactly( + 'rspec: circular variable reference detected: ["A", "B", "C"]') + end + + context 'with job:rules:[if:]' do + let(:attributes) { { name: 'rspec', ref: 'master', rules: [{ if: '$C != null', when: 'always' }] } } + + it "included? does not raise" do + expect { subject.included? }.not_to raise_error + end + + it "included? returns true" do + expect(subject.included?).to eq(true) + end + end + end + end + end end diff --git a/spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb index 664aaaedf7b..99196d393c6 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb @@ -88,6 +88,55 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Environment do end end + context 'when job has deployment tier attribute' do + let(:attributes) do + { + environment: 'customer-portal', + options: { + environment: { + name: 'customer-portal', + deployment_tier: deployment_tier + } + } + } + end + + let(:deployment_tier) { 'production' } + + context 'when environment has not been created yet' do + it 'sets the specified deployment tier' do + is_expected.to be_production + end + + context 'when deployment tier is staging' do + let(:deployment_tier) { 'staging' } + + it 'sets the specified deployment tier' do + is_expected.to be_staging + end + end + + context 'when deployment tier is unknown' do + let(:deployment_tier) { 'unknown' } + + it 'raises an error' do + expect { subject }.to raise_error(ArgumentError, "'unknown' is not a valid tier") + end + end + end + + context 'when environment has already been created' do + before do + create(:environment, :staging, project: project, name: 'customer-portal') + end + + it 'does not overwrite the specified deployment tier' do + # This is to be updated when a deployment succeeded i.e. Deployments::UpdateEnvironmentService. + is_expected.to be_staging + end + end + end + context 'when job starts a review app' do let(:environment_name) { 'review/$CI_COMMIT_REF_NAME' } let(:expected_environment_name) { "review/#{job.ref}" } diff --git a/spec/lib/gitlab/ci/reports/codequality_reports_comparer_spec.rb b/spec/lib/gitlab/ci/reports/codequality_reports_comparer_spec.rb index 90188b56f5a..b322e55cb5a 100644 --- a/spec/lib/gitlab/ci/reports/codequality_reports_comparer_spec.rb +++ b/spec/lib/gitlab/ci/reports/codequality_reports_comparer_spec.rb @@ -27,6 +27,22 @@ RSpec.describe Gitlab::Ci::Reports::CodequalityReportsComparer do expect(report_status).to eq(described_class::STATUS_SUCCESS) end end + + context 'when head report does not exist' do + let(:head_report) { nil } + + it 'returns status not found' do + expect(report_status).to eq(described_class::STATUS_NOT_FOUND) + end + end + + context 'when base report does not exist' do + let(:base_report) { nil } + + it 'returns status success' do + expect(report_status).to eq(described_class::STATUS_NOT_FOUND) + end + end end describe '#errors_count' do @@ -93,6 +109,14 @@ RSpec.describe Gitlab::Ci::Reports::CodequalityReportsComparer do expect(resolved_count).to be_zero end end + + context 'when base report is nil' do + let(:base_report) { nil } + + it 'returns zero' do + expect(resolved_count).to be_zero + end + end end describe '#total_count' do @@ -140,6 +164,14 @@ RSpec.describe Gitlab::Ci::Reports::CodequalityReportsComparer do expect(total_count).to eq(2) end end + + context 'when base report is nil' do + let(:base_report) { nil } + + it 'returns zero' do + expect(total_count).to be_zero + end + end end describe '#existing_errors' do @@ -177,6 +209,14 @@ RSpec.describe Gitlab::Ci::Reports::CodequalityReportsComparer do expect(existing_errors).to be_empty end end + + context 'when base report is nil' do + let(:base_report) { nil } + + it 'returns an empty array' do + expect(existing_errors).to be_empty + end + end end describe '#new_errors' do @@ -213,6 +253,14 @@ RSpec.describe Gitlab::Ci::Reports::CodequalityReportsComparer do expect(new_errors).to eq([degradation_1]) end end + + context 'when base report is nil' do + let(:base_report) { nil } + + it 'returns an empty array' do + expect(new_errors).to be_empty + end + end end describe '#resolved_errors' do @@ -250,5 +298,13 @@ RSpec.describe Gitlab::Ci::Reports::CodequalityReportsComparer do expect(resolved_errors).to be_empty end end + + context 'when base report is nil' do + let(:base_report) { nil } + + it 'returns an empty array' do + expect(resolved_errors).to be_empty + end + end end end diff --git a/spec/lib/gitlab/ci/reports/reports_comparer_spec.rb b/spec/lib/gitlab/ci/reports/reports_comparer_spec.rb index 1e5e4766583..7ed9270e9a0 100644 --- a/spec/lib/gitlab/ci/reports/reports_comparer_spec.rb +++ b/spec/lib/gitlab/ci/reports/reports_comparer_spec.rb @@ -45,6 +45,22 @@ RSpec.describe Gitlab::Ci::Reports::ReportsComparer do expect(status).to eq('failed') end end + + context 'when base_report is nil' do + let(:base_report) { nil } + + it 'returns status not_found' do + expect(status).to eq('not_found') + end + end + + context 'when head_report is nil' do + let(:head_report) { nil } + + it 'returns status not_found' do + expect(status).to eq('not_found') + end + end end describe '#success?' do @@ -94,4 +110,22 @@ RSpec.describe Gitlab::Ci::Reports::ReportsComparer do expect { total_count }.to raise_error(NotImplementedError) end end + + describe '#not_found?' do + subject(:not_found) { comparer.not_found? } + + context 'when base report is nil' do + let(:base_report) { nil } + + it { is_expected.to be_truthy } + end + + context 'when base report exists' do + before do + allow(comparer).to receive(:success?).and_return(true) + end + + it { is_expected.to be_falsey } + end + end end diff --git a/spec/lib/gitlab/ci/reports/test_suite_summary_spec.rb b/spec/lib/gitlab/ci/reports/test_suite_summary_spec.rb index a98d3db4e82..9acea852832 100644 --- a/spec/lib/gitlab/ci/reports/test_suite_summary_spec.rb +++ b/spec/lib/gitlab/ci/reports/test_suite_summary_spec.rb @@ -87,12 +87,44 @@ RSpec.describe Gitlab::Ci::Reports::TestSuiteSummary do end end + describe '#suite_error' do + subject(:suite_error) { test_suite_summary.suite_error } + + context 'when there are no build report results with suite errors' do + it { is_expected.to be_nil } + end + + context 'when there are build report results with suite errors' do + let(:build_report_result_1) do + build( + :ci_build_report_result, + :with_junit_suite_error, + test_suite_name: 'karma', + test_suite_error: 'karma parsing error' + ) + end + + let(:build_report_result_2) do + build( + :ci_build_report_result, + :with_junit_suite_error, + test_suite_name: 'karma', + test_suite_error: 'another karma parsing error' + ) + end + + it 'includes the first suite error from the collection of build report results' do + expect(suite_error).to eq('karma parsing error') + end + end + end + describe '#to_h' do subject { test_suite_summary.to_h } context 'when test suite summary has several build report results' do it 'returns the total as a hash' do - expect(subject).to include(:time, :count, :success, :failed, :skipped, :error) + expect(subject).to include(:time, :count, :success, :failed, :skipped, :error, :suite_error) end end end diff --git a/spec/lib/gitlab/ci/status/composite_spec.rb b/spec/lib/gitlab/ci/status/composite_spec.rb index bcfb9f19792..543cfe874ca 100644 --- a/spec/lib/gitlab/ci/status/composite_spec.rb +++ b/spec/lib/gitlab/ci/status/composite_spec.rb @@ -69,6 +69,8 @@ RSpec.describe Gitlab::Ci::Status::Composite do %i(manual) | false | 'skipped' | false %i(skipped failed) | false | 'success' | true %i(skipped failed) | true | 'skipped' | true + %i(success manual) | true | 'skipped' | false + %i(success manual) | false | 'success' | false %i(created failed) | false | 'created' | true %i(preparing manual) | false | 'preparing' | false end @@ -80,6 +82,25 @@ RSpec.describe Gitlab::Ci::Status::Composite do it_behaves_like 'compares status and warnings' end + + context 'when FF ci_fix_pipeline_status_for_dag_needs_manual is disabled' do + before do + stub_feature_flags(ci_fix_pipeline_status_for_dag_needs_manual: false) + end + + where(:build_statuses, :dag, :result, :has_warnings) do + %i(success manual) | true | 'pending' | false + %i(success manual) | false | 'success' | false + end + + with_them do + let(:all_statuses) do + build_statuses.map { |status| @statuses_with_allow_failure[status] } + end + + it_behaves_like 'compares status and warnings' + end + end end end end diff --git a/spec/lib/gitlab/ci/status/factory_spec.rb b/spec/lib/gitlab/ci/status/factory_spec.rb index 641cb0183d3..94a6255f1e2 100644 --- a/spec/lib/gitlab/ci/status/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/factory_spec.rb @@ -134,4 +134,14 @@ RSpec.describe Gitlab::Ci::Status::Factory do it_behaves_like 'compound decorator factory' end end + + context 'behaviour of FactoryBot traits that create associations' do + context 'creating a namespace with an associated aggregation_schedule record' do + it 'creates only one Namespace record and one Namespace::AggregationSchedule record' do + expect { create(:namespace, :with_aggregation_schedule) } + .to change { Namespace.count }.by(1) + .and change { Namespace::AggregationSchedule.count }.by(1) + end + end + end end diff --git a/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb index f9d6fe24e70..6dfcecb853a 100644 --- a/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb +++ b/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb @@ -3,252 +3,260 @@ require 'spec_helper' RSpec.describe 'Auto-DevOps.gitlab-ci.yml' do + using RSpec::Parameterized::TableSyntax + subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Auto-DevOps') } - describe 'the created pipeline' do - let(:default_branch) { 'master' } - let(:pipeline_branch) { default_branch } - let(:project) { create(:project, :auto_devops, :custom_repo, files: { 'README.md' => '' }) } - let(:user) { project.owner } - let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch ) } - let(:pipeline) { service.execute!(:push) } - let(:build_names) { pipeline.builds.pluck(:name) } - - before do - stub_ci_pipeline_yaml_file(template.content) - allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true) - allow(project).to receive(:default_branch).and_return(default_branch) - end + where(:default_branch) do + %w[master main] + end - shared_examples 'no Kubernetes deployment job' do - it 'does not create any Kubernetes deployment-related builds' do - expect(build_names).not_to include('production') - expect(build_names).not_to include('production_manual') - expect(build_names).not_to include('staging') - expect(build_names).not_to include('canary') - expect(build_names).not_to include('review') - expect(build_names).not_to include(a_string_matching(/rollout \d+%/)) - end - end + with_them do + describe 'the created pipeline' do + let(:pipeline_branch) { default_branch } + let(:project) { create(:project, :auto_devops, :custom_repo, files: { 'README.md' => '' }) } + let(:user) { project.owner } + let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch ) } + let(:pipeline) { service.execute!(:push) } + let(:build_names) { pipeline.builds.pluck(:name) } - it 'creates a build and a test job' do - expect(build_names).to include('build', 'test') - end + before do + stub_application_setting(default_branch_name: default_branch) + stub_ci_pipeline_yaml_file(template.content) + allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true) + end - context 'when the project is set for deployment to AWS' do - let(:platform_value) { 'ECS' } - let(:review_prod_build_names) { build_names.select {|n| n.include?('review') || n.include?('production')} } + shared_examples 'no Kubernetes deployment job' do + it 'does not create any Kubernetes deployment-related builds' do + expect(build_names).not_to include('production') + expect(build_names).not_to include('production_manual') + expect(build_names).not_to include('staging') + expect(build_names).not_to include('canary') + expect(build_names).not_to include('review') + expect(build_names).not_to include(a_string_matching(/rollout \d+%/)) + end + end - before do - create(:ci_variable, project: project, key: 'AUTO_DEVOPS_PLATFORM_TARGET', value: platform_value) + it 'creates a build and a test job' do + expect(build_names).to include('build', 'test') end - shared_examples 'no ECS job when AUTO_DEVOPS_PLATFORM_TARGET is not present' do |job_name| - context 'when AUTO_DEVOPS_PLATFORM_TARGET is nil' do - let(:platform_value) { nil } + context 'when the project is set for deployment to AWS' do + let(:platform_value) { 'ECS' } + let(:review_prod_build_names) { build_names.select {|n| n.include?('review') || n.include?('production')} } - it 'does not trigger the job' do - expect(build_names).not_to include(job_name) - end + before do + create(:ci_variable, project: project, key: 'AUTO_DEVOPS_PLATFORM_TARGET', value: platform_value) end - context 'when AUTO_DEVOPS_PLATFORM_TARGET is empty' do - let(:platform_value) { '' } + shared_examples 'no ECS job when AUTO_DEVOPS_PLATFORM_TARGET is not present' do |job_name| + context 'when AUTO_DEVOPS_PLATFORM_TARGET is nil' do + let(:platform_value) { nil } - it 'does not trigger the job' do - expect(build_names).not_to include(job_name) + it 'does not trigger the job' do + expect(build_names).not_to include(job_name) + end end - end - end - it_behaves_like 'no Kubernetes deployment job' + context 'when AUTO_DEVOPS_PLATFORM_TARGET is empty' do + let(:platform_value) { '' } - it_behaves_like 'no ECS job when AUTO_DEVOPS_PLATFORM_TARGET is not present' do - let(:job_name) { 'production_ecs' } - end + it 'does not trigger the job' do + expect(build_names).not_to include(job_name) + end + end + end - it 'creates an ECS deployment job for production only' do - expect(review_prod_build_names).to contain_exactly('production_ecs') - end + it_behaves_like 'no Kubernetes deployment job' - context 'with FARGATE as a launch type' do - let(:platform_value) { 'FARGATE' } + it_behaves_like 'no ECS job when AUTO_DEVOPS_PLATFORM_TARGET is not present' do + let(:job_name) { 'production_ecs' } + end - it 'creates a FARGATE deployment job for production only' do - expect(review_prod_build_names).to contain_exactly('production_fargate') + it 'creates an ECS deployment job for production only' do + expect(review_prod_build_names).to contain_exactly('production_ecs') end - end - context 'and we are not on the default branch' do - let(:platform_value) { 'ECS' } - let(:pipeline_branch) { 'patch-1' } + context 'with FARGATE as a launch type' do + let(:platform_value) { 'FARGATE' } - before do - project.repository.create_branch(pipeline_branch) + it 'creates a FARGATE deployment job for production only' do + expect(review_prod_build_names).to contain_exactly('production_fargate') + end end - %w(review_ecs review_fargate).each do |job| - it_behaves_like 'no ECS job when AUTO_DEVOPS_PLATFORM_TARGET is not present' do - let(:job_name) { job } + context 'and we are not on the default branch' do + let(:platform_value) { 'ECS' } + let(:pipeline_branch) { 'patch-1' } + + before do + project.repository.create_branch(pipeline_branch, default_branch) end - end - it 'creates an ECS deployment job for review only' do - expect(review_prod_build_names).to contain_exactly('review_ecs', 'stop_review_ecs') - end + %w(review_ecs review_fargate).each do |job| + it_behaves_like 'no ECS job when AUTO_DEVOPS_PLATFORM_TARGET is not present' do + let(:job_name) { job } + end + end - context 'with FARGATE as a launch type' do - let(:platform_value) { 'FARGATE' } + it 'creates an ECS deployment job for review only' do + expect(review_prod_build_names).to contain_exactly('review_ecs', 'stop_review_ecs') + end + + context 'with FARGATE as a launch type' do + let(:platform_value) { 'FARGATE' } - it 'creates an FARGATE deployment job for review only' do - expect(review_prod_build_names).to contain_exactly('review_fargate', 'stop_review_fargate') + it 'creates an FARGATE deployment job for review only' do + expect(review_prod_build_names).to contain_exactly('review_fargate', 'stop_review_fargate') + end end end - end - context 'and when the project has an active cluster' do - let(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) } + context 'and when the project has an active cluster' do + let(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) } - before do - allow(cluster).to receive(:active?).and_return(true) - end + before do + allow(cluster).to receive(:active?).and_return(true) + end - context 'on default branch' do - it 'triggers the deployment to Kubernetes, not to ECS' do - expect(build_names).not_to include('review') - expect(build_names).to include('production') - expect(build_names).not_to include('production_ecs') - expect(build_names).not_to include('review_ecs') + context 'on default branch' do + it 'triggers the deployment to Kubernetes, not to ECS' do + expect(build_names).not_to include('review') + expect(build_names).to include('production') + expect(build_names).not_to include('production_ecs') + expect(build_names).not_to include('review_ecs') + end end end - end - context 'when the platform target is EC2' do - let(:platform_value) { 'EC2' } + context 'when the platform target is EC2' do + let(:platform_value) { 'EC2' } - it 'contains the build_artifact job, not the build job' do - expect(build_names).to include('build_artifact') - expect(build_names).not_to include('build') + it 'contains the build_artifact job, not the build job' do + expect(build_names).to include('build_artifact') + expect(build_names).not_to include('build') + end end end - end - - context 'when the project has no active cluster' do - it 'only creates a build and a test stage' do - expect(pipeline.stages_names).to eq(%w(build test)) - end - it_behaves_like 'no Kubernetes deployment job' - end + context 'when the project has no active cluster' do + it 'only creates a build and a test stage' do + expect(pipeline.stages_names).to eq(%w(build test)) + end - context 'when the project has an active cluster' do - let!(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) } - - describe 'deployment-related builds' do - context 'on default branch' do - it 'does not include rollout jobs besides production' do - expect(build_names).to include('production') - expect(build_names).not_to include('production_manual') - expect(build_names).not_to include('staging') - expect(build_names).not_to include('canary') - expect(build_names).not_to include('review') - expect(build_names).not_to include(a_string_matching(/rollout \d+%/)) - end + it_behaves_like 'no Kubernetes deployment job' + end - context 'when STAGING_ENABLED=1' do - before do - create(:ci_variable, project: project, key: 'STAGING_ENABLED', value: '1') - end + context 'when the project has an active cluster' do + let!(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) } - it 'includes a staging job and a production_manual job' do - expect(build_names).not_to include('production') - expect(build_names).to include('production_manual') - expect(build_names).to include('staging') + describe 'deployment-related builds' do + context 'on default branch' do + it 'does not include rollout jobs besides production' do + expect(build_names).to include('production') + expect(build_names).not_to include('production_manual') + expect(build_names).not_to include('staging') expect(build_names).not_to include('canary') expect(build_names).not_to include('review') expect(build_names).not_to include(a_string_matching(/rollout \d+%/)) end + + context 'when STAGING_ENABLED=1' do + before do + create(:ci_variable, project: project, key: 'STAGING_ENABLED', value: '1') + end + + it 'includes a staging job and a production_manual job' do + expect(build_names).not_to include('production') + expect(build_names).to include('production_manual') + expect(build_names).to include('staging') + expect(build_names).not_to include('canary') + expect(build_names).not_to include('review') + expect(build_names).not_to include(a_string_matching(/rollout \d+%/)) + end + end + + context 'when CANARY_ENABLED=1' do + before do + create(:ci_variable, project: project, key: 'CANARY_ENABLED', value: '1') + end + + it 'includes a canary job and a production_manual job' do + expect(build_names).not_to include('production') + expect(build_names).to include('production_manual') + expect(build_names).not_to include('staging') + expect(build_names).to include('canary') + expect(build_names).not_to include('review') + expect(build_names).not_to include(a_string_matching(/rollout \d+%/)) + end + end end - context 'when CANARY_ENABLED=1' do + context 'outside of default branch' do + let(:pipeline_branch) { 'patch-1' } + before do - create(:ci_variable, project: project, key: 'CANARY_ENABLED', value: '1') + project.repository.create_branch(pipeline_branch, default_branch) end - it 'includes a canary job and a production_manual job' do + it 'does not include rollout jobs besides review' do expect(build_names).not_to include('production') - expect(build_names).to include('production_manual') + expect(build_names).not_to include('production_manual') expect(build_names).not_to include('staging') - expect(build_names).to include('canary') - expect(build_names).not_to include('review') + expect(build_names).not_to include('canary') + expect(build_names).to include('review') expect(build_names).not_to include(a_string_matching(/rollout \d+%/)) end end end - - context 'outside of default branch' do - let(:pipeline_branch) { 'patch-1' } - - before do - project.repository.create_branch(pipeline_branch) - end - - it 'does not include rollout jobs besides review' do - expect(build_names).not_to include('production') - expect(build_names).not_to include('production_manual') - expect(build_names).not_to include('staging') - expect(build_names).not_to include('canary') - expect(build_names).to include('review') - expect(build_names).not_to include(a_string_matching(/rollout \d+%/)) - end - end end end - end - describe 'build-pack detection' do - using RSpec::Parameterized::TableSyntax - - where(:case_name, :files, :variables, :include_build_names, :not_include_build_names) do - 'No match' | { 'README.md' => '' } | {} | %w() | %w(build test) - 'Buildpack' | { 'README.md' => '' } | { 'BUILDPACK_URL' => 'http://example.com' } | %w(build test) | %w() - 'Explicit set' | { 'README.md' => '' } | { 'AUTO_DEVOPS_EXPLICITLY_ENABLED' => '1' } | %w(build test) | %w() - 'Explicit unset' | { 'README.md' => '' } | { 'AUTO_DEVOPS_EXPLICITLY_ENABLED' => '0' } | %w() | %w(build test) - 'DOCKERFILE_PATH' | { 'README.md' => '' } | { 'DOCKERFILE_PATH' => 'Docker.file' } | %w(build test) | %w() - 'Dockerfile' | { 'Dockerfile' => '' } | {} | %w(build test) | %w() - 'Clojure' | { 'project.clj' => '' } | {} | %w(build test) | %w() - 'Go modules' | { 'go.mod' => '' } | {} | %w(build test) | %w() - 'Go gb' | { 'src/gitlab.com/gopackage.go' => '' } | {} | %w(build test) | %w() - 'Gradle' | { 'gradlew' => '' } | {} | %w(build test) | %w() - 'Java' | { 'pom.xml' => '' } | {} | %w(build test) | %w() - 'Multi-buildpack' | { '.buildpacks' => '' } | {} | %w(build test) | %w() - 'NodeJS' | { 'package.json' => '' } | {} | %w(build test) | %w() - 'PHP' | { 'composer.json' => '' } | {} | %w(build test) | %w() - 'Play' | { 'conf/application.conf' => '' } | {} | %w(build test) | %w() - 'Python' | { 'Pipfile' => '' } | {} | %w(build test) | %w() - 'Ruby' | { 'Gemfile' => '' } | {} | %w(build test) | %w() - 'Scala' | { 'build.sbt' => '' } | {} | %w(build test) | %w() - 'Static' | { '.static' => '' } | {} | %w(build test) | %w() - end + describe 'build-pack detection' do + using RSpec::Parameterized::TableSyntax + + where(:case_name, :files, :variables, :include_build_names, :not_include_build_names) do + 'No match' | { 'README.md' => '' } | {} | %w() | %w(build test) + 'Buildpack' | { 'README.md' => '' } | { 'BUILDPACK_URL' => 'http://example.com' } | %w(build test) | %w() + 'Explicit set' | { 'README.md' => '' } | { 'AUTO_DEVOPS_EXPLICITLY_ENABLED' => '1' } | %w(build test) | %w() + 'Explicit unset' | { 'README.md' => '' } | { 'AUTO_DEVOPS_EXPLICITLY_ENABLED' => '0' } | %w() | %w(build test) + 'DOCKERFILE_PATH' | { 'README.md' => '' } | { 'DOCKERFILE_PATH' => 'Docker.file' } | %w(build test) | %w() + 'Dockerfile' | { 'Dockerfile' => '' } | {} | %w(build test) | %w() + 'Clojure' | { 'project.clj' => '' } | {} | %w(build test) | %w() + 'Go modules' | { 'go.mod' => '' } | {} | %w(build test) | %w() + 'Go gb' | { 'src/gitlab.com/gopackage.go' => '' } | {} | %w(build test) | %w() + 'Gradle' | { 'gradlew' => '' } | {} | %w(build test) | %w() + 'Java' | { 'pom.xml' => '' } | {} | %w(build test) | %w() + 'Multi-buildpack' | { '.buildpacks' => '' } | {} | %w(build test) | %w() + 'NodeJS' | { 'package.json' => '' } | {} | %w(build test) | %w() + 'PHP' | { 'composer.json' => '' } | {} | %w(build test) | %w() + 'Play' | { 'conf/application.conf' => '' } | {} | %w(build test) | %w() + 'Python' | { 'Pipfile' => '' } | {} | %w(build test) | %w() + 'Ruby' | { 'Gemfile' => '' } | {} | %w(build test) | %w() + 'Scala' | { 'build.sbt' => '' } | {} | %w(build test) | %w() + 'Static' | { '.static' => '' } | {} | %w(build test) | %w() + end - with_them do - let(:project) { create(:project, :custom_repo, files: files) } - let(:user) { project.owner } - let(:service) { Ci::CreatePipelineService.new(project, user, ref: 'master' ) } - let(:pipeline) { service.execute(:push) } - let(:build_names) { pipeline.builds.pluck(:name) } + with_them do + let(:project) { create(:project, :custom_repo, files: files) } + let(:user) { project.owner } + let(:service) { Ci::CreatePipelineService.new(project, user, ref: default_branch ) } + let(:pipeline) { service.execute(:push) } + let(:build_names) { pipeline.builds.pluck(:name) } - before do - stub_ci_pipeline_yaml_file(template.content) - allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true) - variables.each do |(key, value)| - create(:ci_variable, project: project, key: key, value: value) + before do + stub_application_setting(default_branch_name: default_branch) + stub_ci_pipeline_yaml_file(template.content) + allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true) + variables.each do |(key, value)| + create(:ci_variable, project: project, key: key, value: value) + end end - end - it 'creates a pipeline with the expected jobs' do - expect(build_names).to include(*include_build_names) - expect(build_names).not_to include(*not_include_build_names) + it 'creates a pipeline with the expected jobs' do + expect(build_names).to include(*include_build_names) + expect(build_names).not_to include(*not_include_build_names) + end end end end diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb index 92bf2519588..597e4ca9b03 100644 --- a/spec/lib/gitlab/ci/trace_spec.rb +++ b/spec/lib/gitlab/ci/trace_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Trace, :clean_gitlab_redis_shared_state, factory_default: :keep do - let_it_be(:project) { create_default(:project) } + let_it_be(:project) { create_default(:project).freeze } let_it_be_with_reload(:build) { create(:ci_build) } let(:trace) { described_class.new(build) } diff --git a/spec/lib/gitlab/ci/variables/collection/item_spec.rb b/spec/lib/gitlab/ci/variables/collection/item_spec.rb index 2e43f22830a..ca9dc95711d 100644 --- a/spec/lib/gitlab/ci/variables/collection/item_spec.rb +++ b/spec/lib/gitlab/ci/variables/collection/item_spec.rb @@ -32,6 +32,7 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Item do it 'saves given value' do expect(subject[:key]).to eq variable_key expect(subject[:value]).to eq expected_value + expect(subject.value).to eq expected_value end end @@ -69,6 +70,47 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Item do end end + describe '#depends_on' do + let(:item) { Gitlab::Ci::Variables::Collection::Item.new(**variable) } + + subject { item.depends_on } + + context 'table tests' do + using RSpec::Parameterized::TableSyntax + + where do + { + "no variable references": { + variable: { key: 'VAR', value: 'something' }, + expected_depends_on: nil + }, + "simple variable reference": { + variable: { key: 'VAR', value: 'something_$VAR2' }, + expected_depends_on: %w(VAR2) + }, + "complex expansion": { + variable: { key: 'VAR', value: 'something_${VAR2}_$VAR3' }, + expected_depends_on: %w(VAR2 VAR3) + }, + "complex expansion in raw variable": { + variable: { key: 'VAR', value: 'something_${VAR2}_$VAR3', raw: true }, + expected_depends_on: nil + }, + "complex expansions for Windows": { + variable: { key: 'variable3', value: 'key%variable%%variable2%' }, + expected_depends_on: %w(variable variable2) + } + } + end + + with_them do + it 'contains referenced variable names' do + is_expected.to eq(expected_depends_on) + end + end + end + end + describe '.fabricate' do it 'supports using a hash' do resource = described_class.fabricate(variable) @@ -118,6 +160,26 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Item do end end + describe '#raw' do + it 'returns false when :raw is not specified' do + item = described_class.new(**variable) + + expect(item.raw).to eq false + end + + context 'when :raw is specified as true' do + let(:variable) do + { key: variable_key, value: variable_value, public: true, masked: false, raw: true } + end + + it 'returns true' do + item = described_class.new(**variable) + + expect(item.raw).to eq true + end + end + end + describe '#to_runner_variable' do context 'when variable is not a file-related' do it 'returns a runner-compatible hash representation' do @@ -139,5 +201,47 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Item do .to eq(key: 'VAR', value: 'value', public: true, file: true, masked: false) end end + + context 'when variable is raw' do + it 'does not export raw value when it is false' do + runner_variable = described_class + .new(key: 'VAR', value: 'value', raw: false) + .to_runner_variable + + expect(runner_variable) + .to eq(key: 'VAR', value: 'value', public: true, masked: false) + end + + it 'exports raw value when it is true' do + runner_variable = described_class + .new(key: 'VAR', value: 'value', raw: true) + .to_runner_variable + + expect(runner_variable) + .to eq(key: 'VAR', value: 'value', public: true, raw: true, masked: false) + end + end + + context 'when referencing a variable' do + it '#depends_on contains names of dependencies' do + runner_variable = described_class.new(key: 'CI_VAR', value: '${CI_VAR_2}-123-$CI_VAR_3') + + expect(runner_variable.depends_on).to eq(%w(CI_VAR_2 CI_VAR_3)) + end + end + + context 'when assigned the raw attribute' do + it 'retains a true raw attribute' do + runner_variable = described_class.new(key: 'CI_VAR', value: '123', raw: true) + + expect(runner_variable).to eq(key: 'CI_VAR', value: '123', public: true, masked: false, raw: true) + end + + it 'does not retain a false raw attribute' do + runner_variable = described_class.new(key: 'CI_VAR', value: '123', raw: false) + + expect(runner_variable).to eq(key: 'CI_VAR', value: '123', public: true, masked: false) + end + end end end diff --git a/spec/lib/gitlab/ci/variables/collection/sort_spec.rb b/spec/lib/gitlab/ci/variables/collection/sort_spec.rb new file mode 100644 index 00000000000..73cf0e19d00 --- /dev/null +++ b/spec/lib/gitlab/ci/variables/collection/sort_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Variables::Collection::Sort do + describe '#initialize with non-Collection value' do + context 'when FF :variable_inside_variable is disabled' do + subject { Gitlab::Ci::Variables::Collection::Sort.new([]) } + + it 'raises ArgumentError' do + expect { subject }.to raise_error(ArgumentError, /Collection object was expected/) + end + end + + context 'when FF :variable_inside_variable is enabled' do + subject { Gitlab::Ci::Variables::Collection::Sort.new([]) } + + it 'raises ArgumentError' do + expect { subject }.to raise_error(ArgumentError, /Collection object was expected/) + end + end + end + + describe '#errors' do + context 'table tests' do + using RSpec::Parameterized::TableSyntax + + where do + { + "empty array": { + variables: [], + expected_errors: nil + }, + "simple expansions": { + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' }, + { key: 'variable3', value: 'key$variable$variable2' } + ], + expected_errors: nil + }, + "cyclic dependency": { + variables: [ + { key: 'variable', value: '$variable2' }, + { key: 'variable2', value: '$variable3' }, + { key: 'variable3', value: 'key$variable$variable2' } + ], + expected_errors: 'circular variable reference detected: ["variable", "variable2", "variable3"]' + }, + "array with raw variable": { + variables: [ + { key: 'variable', value: '$variable2' }, + { key: 'variable2', value: '$variable3' }, + { key: 'variable3', value: 'key$variable$variable2', raw: true } + ], + expected_errors: nil + }, + "variable containing escaped variable reference": { + variables: [ + { key: 'variable_a', value: 'value' }, + { key: 'variable_b', value: '$$variable_a' }, + { key: 'variable_c', value: '$variable_b' } + ], + expected_errors: nil + } + } + end + + with_them do + let(:collection) { Gitlab::Ci::Variables::Collection.new(variables) } + + subject { Gitlab::Ci::Variables::Collection::Sort.new(collection) } + + it 'errors matches expected errors' do + expect(subject.errors).to eq(expected_errors) + end + + it 'valid? matches expected errors' do + expect(subject.valid?).to eq(expected_errors.nil?) + end + + it 'does not raise' do + expect { subject }.not_to raise_error + end + end + end + end + + describe '#tsort' do + context 'table tests' do + using RSpec::Parameterized::TableSyntax + + where do + { + "empty array": { + variables: [], + result: [] + }, + "simple expansions, no reordering needed": { + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' }, + { key: 'variable3', value: 'key$variable$variable2' } + ], + result: %w[variable variable2 variable3] + }, + "complex expansion, reordering needed": { + variables: [ + { key: 'variable2', value: 'key${variable}' }, + { key: 'variable', value: 'value' } + ], + result: %w[variable variable2] + }, + "unused variables": { + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable4', value: 'key$variable$variable3' }, + { key: 'variable2', value: 'result2' }, + { key: 'variable3', value: 'result3' } + ], + result: %w[variable variable3 variable4 variable2] + }, + "missing variable": { + variables: [ + { key: 'variable2', value: 'key$variable' } + ], + result: %w[variable2] + }, + "complex expansions with missing variable": { + variables: [ + { key: 'variable4', value: 'key${variable}${variable2}${variable3}' }, + { key: 'variable', value: 'value' }, + { key: 'variable3', value: 'value3' } + ], + result: %w[variable variable3 variable4] + }, + "raw variable does not get resolved": { + variables: [ + { key: 'variable', value: '$variable2' }, + { key: 'variable2', value: '$variable3' }, + { key: 'variable3', value: 'key$variable$variable2', raw: true } + ], + result: %w[variable3 variable2 variable] + }, + "variable containing escaped variable reference": { + variables: [ + { key: 'variable_c', value: '$variable_b' }, + { key: 'variable_b', value: '$$variable_a' }, + { key: 'variable_a', value: 'value' } + ], + result: %w[variable_a variable_b variable_c] + } + } + end + + with_them do + let(:collection) { Gitlab::Ci::Variables::Collection.new(variables) } + + subject { Gitlab::Ci::Variables::Collection::Sort.new(collection).tsort } + + it 'returns correctly sorted variables' do + expect(subject.pluck(:key)).to eq(result) + end + end + end + + context 'cyclic dependency' do + let(:variables) do + [ + { key: 'variable2', value: '$variable3' }, + { key: 'variable3', value: 'key$variable$variable2' }, + { key: 'variable', value: '$variable2' } + ] + end + + let(:collection) { Gitlab::Ci::Variables::Collection.new(variables) } + + subject { Gitlab::Ci::Variables::Collection::Sort.new(collection).tsort } + + it 'raises TSort::Cyclic' do + expect { subject }.to raise_error(TSort::Cyclic) + end + end + end +end diff --git a/spec/lib/gitlab/ci/variables/collection/sorted_spec.rb b/spec/lib/gitlab/ci/variables/collection/sorted_spec.rb deleted file mode 100644 index 954273fd41e..00000000000 --- a/spec/lib/gitlab/ci/variables/collection/sorted_spec.rb +++ /dev/null @@ -1,259 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Ci::Variables::Collection::Sorted do - describe '#errors' do - context 'when FF :variable_inside_variable is disabled' do - let_it_be(:project_with_flag_disabled) { create(:project) } - let_it_be(:project_with_flag_enabled) { create(:project) } - - before do - stub_feature_flags(variable_inside_variable: [project_with_flag_enabled]) - end - - context 'table tests' do - using RSpec::Parameterized::TableSyntax - - where do - { - "empty array": { - variables: [] - }, - "simple expansions": { - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' }, - { key: 'variable3', value: 'key$variable$variable2' } - ] - }, - "complex expansion": { - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'key${variable}' } - ] - }, - "complex expansions with missing variable for Windows": { - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable3', value: 'key%variable%%variable2%' } - ] - }, - "out-of-order variable reference": { - variables: [ - { key: 'variable2', value: 'key${variable}' }, - { key: 'variable', value: 'value' } - ] - }, - "array with cyclic dependency": { - variables: [ - { key: 'variable', value: '$variable2' }, - { key: 'variable2', value: '$variable3' }, - { key: 'variable3', value: 'key$variable$variable2' } - ] - } - } - end - - with_them do - subject { Gitlab::Ci::Variables::Collection::Sorted.new(variables, project_with_flag_disabled) } - - it 'does not report error' do - expect(subject.errors).to eq(nil) - end - - it 'valid? reports true' do - expect(subject.valid?).to eq(true) - end - end - end - end - - context 'when FF :variable_inside_variable is enabled' do - let_it_be(:project_with_flag_disabled) { create(:project) } - let_it_be(:project_with_flag_enabled) { create(:project) } - - before do - stub_feature_flags(variable_inside_variable: [project_with_flag_enabled]) - end - - context 'table tests' do - using RSpec::Parameterized::TableSyntax - - where do - { - "empty array": { - variables: [], - validation_result: nil - }, - "simple expansions": { - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' }, - { key: 'variable3', value: 'key$variable$variable2' } - ], - validation_result: nil - }, - "cyclic dependency": { - variables: [ - { key: 'variable', value: '$variable2' }, - { key: 'variable2', value: '$variable3' }, - { key: 'variable3', value: 'key$variable$variable2' } - ], - validation_result: 'circular variable reference detected: ["variable", "variable2", "variable3"]' - } - } - end - - with_them do - subject { Gitlab::Ci::Variables::Collection::Sorted.new(variables, project_with_flag_enabled) } - - it 'errors matches expected validation result' do - expect(subject.errors).to eq(validation_result) - end - - it 'valid? matches expected validation result' do - expect(subject.valid?).to eq(validation_result.nil?) - end - end - end - end - end - - describe '#sort' do - context 'when FF :variable_inside_variable is disabled' do - before do - stub_feature_flags(variable_inside_variable: false) - end - - context 'table tests' do - using RSpec::Parameterized::TableSyntax - - where do - { - "empty array": { - variables: [] - }, - "simple expansions": { - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' }, - { key: 'variable3', value: 'key$variable$variable2' } - ] - }, - "complex expansion": { - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'key${variable}' } - ] - }, - "complex expansions with missing variable for Windows": { - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable3', value: 'key%variable%%variable2%' } - ] - }, - "out-of-order variable reference": { - variables: [ - { key: 'variable2', value: 'key${variable}' }, - { key: 'variable', value: 'value' } - ] - }, - "array with cyclic dependency": { - variables: [ - { key: 'variable', value: '$variable2' }, - { key: 'variable2', value: '$variable3' }, - { key: 'variable3', value: 'key$variable$variable2' } - ] - } - } - end - - with_them do - let_it_be(:project) { create(:project) } - subject { Gitlab::Ci::Variables::Collection::Sorted.new(variables, project) } - - it 'does not expand variables' do - expect(subject.sort).to eq(variables) - end - end - end - end - - context 'when FF :variable_inside_variable is enabled' do - before do - stub_licensed_features(group_saml_group_sync: true) - stub_feature_flags(saml_group_links: true) - stub_feature_flags(variable_inside_variable: true) - end - - context 'table tests' do - using RSpec::Parameterized::TableSyntax - - where do - { - "empty array": { - variables: [], - result: [] - }, - "simple expansions, no reordering needed": { - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable2', value: 'result' }, - { key: 'variable3', value: 'key$variable$variable2' } - ], - result: %w[variable variable2 variable3] - }, - "complex expansion, reordering needed": { - variables: [ - { key: 'variable2', value: 'key${variable}' }, - { key: 'variable', value: 'value' } - ], - result: %w[variable variable2] - }, - "unused variables": { - variables: [ - { key: 'variable', value: 'value' }, - { key: 'variable4', value: 'key$variable$variable3' }, - { key: 'variable2', value: 'result2' }, - { key: 'variable3', value: 'result3' } - ], - result: %w[variable variable3 variable4 variable2] - }, - "missing variable": { - variables: [ - { key: 'variable2', value: 'key$variable' } - ], - result: %w[variable2] - }, - "complex expansions with missing variable": { - variables: [ - { key: 'variable4', value: 'key${variable}${variable2}${variable3}' }, - { key: 'variable', value: 'value' }, - { key: 'variable3', value: 'value3' } - ], - result: %w[variable variable3 variable4] - }, - "cyclic dependency causes original array to be returned": { - variables: [ - { key: 'variable2', value: '$variable3' }, - { key: 'variable3', value: 'key$variable$variable2' }, - { key: 'variable', value: '$variable2' } - ], - result: %w[variable2 variable3 variable] - } - } - end - - with_them do - let_it_be(:project) { create(:project) } - subject { Gitlab::Ci::Variables::Collection::Sorted.new(variables, project) } - - it 'sort returns correctly sorted variables' do - expect(subject.sort.map { |var| var[:key] }).to eq(result) - end - end - end - end - end -end diff --git a/spec/lib/gitlab/ci/variables/collection_spec.rb b/spec/lib/gitlab/ci/variables/collection_spec.rb index ac84313ad9f..7b77754190a 100644 --- a/spec/lib/gitlab/ci/variables/collection_spec.rb +++ b/spec/lib/gitlab/ci/variables/collection_spec.rb @@ -13,7 +13,7 @@ RSpec.describe Gitlab::Ci::Variables::Collection do end it 'can be initialized without an argument' do - expect(subject).to be_none + is_expected.to be_none end end @@ -21,13 +21,13 @@ RSpec.describe Gitlab::Ci::Variables::Collection do it 'appends a hash' do subject.append(key: 'VARIABLE', value: 'something') - expect(subject).to be_one + is_expected.to be_one end it 'appends a Ci::Variable' do subject.append(build(:ci_variable)) - expect(subject).to be_one + is_expected.to be_one end it 'appends an internal resource' do @@ -35,7 +35,7 @@ RSpec.describe Gitlab::Ci::Variables::Collection do subject.append(collection.first) - expect(subject).to be_one + is_expected.to be_one end it 'returns self' do @@ -98,6 +98,50 @@ RSpec.describe Gitlab::Ci::Variables::Collection do end end + describe '#[]' do + variable = { key: 'VAR', value: 'value', public: true, masked: false } + + collection = described_class.new([variable]) + + it 'returns nil for a non-existent variable name' do + expect(collection['UNKNOWN_VAR']).to be_nil + end + + it 'returns Item for an existent variable name' do + expect(collection['VAR']).to be_an_instance_of(Gitlab::Ci::Variables::Collection::Item) + expect(collection['VAR'].to_runner_variable).to eq(variable) + end + end + + describe '#size' do + it 'returns zero for empty collection' do + collection = described_class.new([]) + + expect(collection.size).to eq(0) + end + + it 'returns 2 for collection with 2 variables' do + collection = described_class.new( + [ + { key: 'VAR1', value: 'value', public: true, masked: false }, + { key: 'VAR2', value: 'value', public: true, masked: false } + ]) + + expect(collection.size).to eq(2) + end + + it 'returns 3 for collection with 2 duplicate variables' do + collection = described_class.new( + [ + { key: 'VAR1', value: 'value', public: true, masked: false }, + { key: 'VAR2', value: 'value', public: true, masked: false }, + { key: 'VAR1', value: 'value', public: true, masked: false } + ]) + + expect(collection.size).to eq(3) + end + end + describe '#to_runner_variables' do it 'creates an array of hashes in a runner-compatible format' do collection = described_class.new([{ key: 'TEST', value: '1' }]) @@ -121,4 +165,338 @@ RSpec.describe Gitlab::Ci::Variables::Collection do expect(collection.to_hash).not_to include(TEST1: 'test-1') end end + + describe '#reject' do + let(:collection) do + described_class.new + .append(key: 'CI_JOB_NAME', value: 'test-1') + .append(key: 'CI_BUILD_ID', value: '1') + .append(key: 'TEST1', value: 'test-3') + end + + subject { collection.reject { |var| var[:key] =~ /\ACI_(JOB|BUILD)/ } } + + it 'returns a Collection instance' do + is_expected.to be_an_instance_of(described_class) + end + + it 'returns correctly filtered Collection' do + comp = collection.to_runner_variables.reject { |var| var[:key] =~ /\ACI_(JOB|BUILD)/ } + expect(subject.to_runner_variables).to eq(comp) + end + end + + describe '#expand_value' do + let(:collection) do + Gitlab::Ci::Variables::Collection.new + .append(key: 'CI_JOB_NAME', value: 'test-1') + .append(key: 'CI_BUILD_ID', value: '1') + .append(key: 'RAW_VAR', value: '$TEST1', raw: true) + .append(key: 'TEST1', value: 'test-3') + end + + context 'table tests' do + using RSpec::Parameterized::TableSyntax + + where do + { + "empty value": { + value: '', + result: '', + keep_undefined: false + }, + "simple expansions": { + value: 'key$TEST1-$CI_BUILD_ID', + result: 'keytest-3-1', + keep_undefined: false + }, + "complex expansion": { + value: 'key${TEST1}-${CI_JOB_NAME}', + result: 'keytest-3-test-1', + keep_undefined: false + }, + "complex expansions with raw variable": { + value: 'key${RAW_VAR}-${CI_JOB_NAME}', + result: 'key$TEST1-test-1', + keep_undefined: false + }, + "missing variable not keeping original": { + value: 'key${MISSING_VAR}-${CI_JOB_NAME}', + result: 'key-test-1', + keep_undefined: false + }, + "missing variable keeping original": { + value: 'key${MISSING_VAR}-${CI_JOB_NAME}', + result: 'key${MISSING_VAR}-test-1', + keep_undefined: true + } + } + end + + with_them do + subject { collection.expand_value(value, keep_undefined: keep_undefined) } + + it 'matches expected expansion' do + is_expected.to eq(result) + end + end + end + end + + describe '#sort_and_expand_all' do + context 'when FF :variable_inside_variable is disabled' do + let_it_be(:project_with_flag_disabled) { create(:project) } + let_it_be(:project_with_flag_enabled) { create(:project) } + + before do + stub_feature_flags(variable_inside_variable: [project_with_flag_enabled]) + end + + context 'table tests' do + using RSpec::Parameterized::TableSyntax + + where do + { + "empty array": { + variables: [], + keep_undefined: false + }, + "simple expansions": { + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' }, + { key: 'variable3', value: 'key$variable$variable2' } + ], + keep_undefined: false + }, + "complex expansion": { + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'key${variable}' } + ], + keep_undefined: false + }, + "out-of-order variable reference": { + variables: [ + { key: 'variable2', value: 'key${variable}' }, + { key: 'variable', value: 'value' } + ], + keep_undefined: false + }, + "complex expansions with raw variable": { + variables: [ + { key: 'variable3', value: 'key_${variable}_${variable2}' }, + { key: 'variable', value: '$variable2', raw: true }, + { key: 'variable2', value: 'value2' } + ], + keep_undefined: false + }, + "array with cyclic dependency": { + variables: [ + { key: 'variable', value: '$variable2' }, + { key: 'variable2', value: '$variable3' }, + { key: 'variable3', value: 'key$variable$variable2' } + ], + keep_undefined: true + } + } + end + + with_them do + let(:collection) { Gitlab::Ci::Variables::Collection.new(variables, keep_undefined: keep_undefined) } + + subject { collection.sort_and_expand_all(project_with_flag_disabled) } + + it 'returns Collection' do + is_expected.to be_an_instance_of(Gitlab::Ci::Variables::Collection) + end + + it 'does not expand variables' do + var_hash = variables.pluck(:key, :value).to_h + expect(subject.to_hash).to eq(var_hash) + end + end + end + end + + context 'when FF :variable_inside_variable is enabled' do + let_it_be(:project_with_flag_disabled) { create(:project) } + let_it_be(:project_with_flag_enabled) { create(:project) } + + before do + stub_feature_flags(variable_inside_variable: [project_with_flag_enabled]) + end + + context 'table tests' do + using RSpec::Parameterized::TableSyntax + + where do + { + "empty array": { + variables: [], + keep_undefined: false, + result: [] + }, + "simple expansions": { + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' }, + { key: 'variable3', value: 'key$variable$variable2' }, + { key: 'variable4', value: 'key$variable$variable3' } + ], + keep_undefined: false, + result: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' }, + { key: 'variable3', value: 'keyvalueresult' }, + { key: 'variable4', value: 'keyvaluekeyvalueresult' } + ] + }, + "complex expansion": { + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'key${variable}' } + ], + keep_undefined: false, + result: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'keyvalue' } + ] + }, + "unused variables": { + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result2' }, + { key: 'variable3', value: 'result3' }, + { key: 'variable4', value: 'key$variable$variable3' } + ], + keep_undefined: false, + result: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result2' }, + { key: 'variable3', value: 'result3' }, + { key: 'variable4', value: 'keyvalueresult3' } + ] + }, + "complex expansions": { + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' }, + { key: 'variable3', value: 'key${variable}${variable2}' } + ], + keep_undefined: false, + result: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' }, + { key: 'variable3', value: 'keyvalueresult' } + ] + }, + "out-of-order expansion": { + variables: [ + { key: 'variable3', value: 'key$variable2$variable' }, + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' } + ], + keep_undefined: false, + result: [ + { key: 'variable2', value: 'result' }, + { key: 'variable', value: 'value' }, + { key: 'variable3', value: 'keyresultvalue' } + ] + }, + "out-of-order complex expansion": { + variables: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' }, + { key: 'variable3', value: 'key${variable2}${variable}' } + ], + keep_undefined: false, + result: [ + { key: 'variable', value: 'value' }, + { key: 'variable2', value: 'result' }, + { key: 'variable3', value: 'keyresultvalue' } + ] + }, + "missing variable": { + variables: [ + { key: 'variable2', value: 'key$variable' } + ], + keep_undefined: false, + result: [ + { key: 'variable2', value: 'key' } + ] + }, + "missing variable keeping original": { + variables: [ + { key: 'variable2', value: 'key$variable' } + ], + keep_undefined: true, + result: [ + { key: 'variable2', value: 'key$variable' } + ] + }, + "complex expansions with missing variable keeping original": { + variables: [ + { key: 'variable4', value: 'key${variable}${variable2}${variable3}' }, + { key: 'variable', value: 'value' }, + { key: 'variable3', value: 'value3' } + ], + keep_undefined: true, + result: [ + { key: 'variable', value: 'value' }, + { key: 'variable3', value: 'value3' }, + { key: 'variable4', value: 'keyvalue${variable2}value3' } + ] + }, + "complex expansions with raw variable": { + variables: [ + { key: 'variable3', value: 'key_${variable}_${variable2}' }, + { key: 'variable', value: '$variable2', raw: true }, + { key: 'variable2', value: 'value2' } + ], + keep_undefined: false, + result: [ + { key: 'variable', value: '$variable2', raw: true }, + { key: 'variable2', value: 'value2' }, + { key: 'variable3', value: 'key_$variable2_value2' } + ] + }, + "cyclic dependency causes original array to be returned": { + variables: [ + { key: 'variable', value: '$variable2' }, + { key: 'variable2', value: '$variable3' }, + { key: 'variable3', value: 'key$variable$variable2' } + ], + keep_undefined: false, + result: [ + { key: 'variable', value: '$variable2' }, + { key: 'variable2', value: '$variable3' }, + { key: 'variable3', value: 'key$variable$variable2' } + ] + } + } + end + + with_them do + let(:collection) { Gitlab::Ci::Variables::Collection.new(variables) } + + subject { collection.sort_and_expand_all(project_with_flag_enabled, keep_undefined: keep_undefined) } + + it 'returns Collection' do + is_expected.to be_an_instance_of(Gitlab::Ci::Variables::Collection) + end + + it 'expands variables' do + var_hash = result.to_h { |env| [env.fetch(:key), env.fetch(:value)] } + .with_indifferent_access + expect(subject.to_hash).to eq(var_hash) + end + + it 'preserves raw attribute' do + expect(subject.pluck(:key, :raw).to_h).to eq(collection.pluck(:key, :raw).to_h) + end + end + end + end + end end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index 9498453852a..5462a587d16 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -1368,6 +1368,155 @@ module Gitlab end end + context 'with multiple_cache_per_job FF disabled' do + before do + stub_feature_flags(multiple_cache_per_job: false) + end + describe 'cache' do + context 'when cache definition has unknown keys' do + let(:config) do + YAML.dump( + { cache: { untracked: true, invalid: 'key' }, + rspec: { script: 'rspec' } }) + end + + it_behaves_like 'returns errors', 'cache config contains unknown keys: invalid' + end + + it "returns cache when defined globally" do + config = YAML.dump({ + cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'key' }, + rspec: { + script: "rspec" + } + }) + + config_processor = Gitlab::Ci::YamlProcessor.new(config).execute + + expect(config_processor.stage_builds_attributes("test").size).to eq(1) + expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq( + paths: ["logs/", "binaries/"], + untracked: true, + key: 'key', + policy: 'pull-push', + when: 'on_success' + ) + end + + it "returns cache when defined in default context" do + config = YAML.dump( + { + default: { + cache: { paths: ["logs/", "binaries/"], untracked: true, key: { files: ['file'] } } + }, + rspec: { + script: "rspec" + } + }) + + config_processor = Gitlab::Ci::YamlProcessor.new(config).execute + + expect(config_processor.stage_builds_attributes("test").size).to eq(1) + expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq( + paths: ["logs/", "binaries/"], + untracked: true, + key: { files: ['file'] }, + policy: 'pull-push', + when: 'on_success' + ) + end + + it 'returns cache key when defined in a job' do + config = YAML.dump({ + rspec: { + cache: { paths: ['logs/', 'binaries/'], untracked: true, key: 'key' }, + script: 'rspec' + } + }) + + config_processor = Gitlab::Ci::YamlProcessor.new(config).execute + + expect(config_processor.stage_builds_attributes('test').size).to eq(1) + expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq( + paths: ['logs/', 'binaries/'], + untracked: true, + key: 'key', + policy: 'pull-push', + when: 'on_success' + ) + end + + it 'returns cache files' do + config = YAML.dump( + rspec: { + cache: { + paths: ['logs/', 'binaries/'], + untracked: true, + key: { files: ['file'] } + }, + script: 'rspec' + } + ) + + config_processor = Gitlab::Ci::YamlProcessor.new(config).execute + + expect(config_processor.stage_builds_attributes('test').size).to eq(1) + expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq( + paths: ['logs/', 'binaries/'], + untracked: true, + key: { files: ['file'] }, + policy: 'pull-push', + when: 'on_success' + ) + end + + it 'returns cache files with prefix' do + config = YAML.dump( + rspec: { + cache: { + paths: ['logs/', 'binaries/'], + untracked: true, + key: { files: ['file'], prefix: 'prefix' } + }, + script: 'rspec' + } + ) + + config_processor = Gitlab::Ci::YamlProcessor.new(config).execute + + expect(config_processor.stage_builds_attributes('test').size).to eq(1) + expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq( + paths: ['logs/', 'binaries/'], + untracked: true, + key: { files: ['file'], prefix: 'prefix' }, + policy: 'pull-push', + when: 'on_success' + ) + end + + it "overwrite cache when defined for a job and globally" do + config = YAML.dump({ + cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'global' }, + rspec: { + script: "rspec", + cache: { paths: ["test/"], untracked: false, key: 'local' } + } + }) + + config_processor = Gitlab::Ci::YamlProcessor.new(config).execute + + expect(config_processor.stage_builds_attributes("test").size).to eq(1) + expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq( + paths: ["test/"], + untracked: false, + key: 'local', + policy: 'pull-push', + when: 'on_success' + ) + end + end + end + describe 'cache' do context 'when cache definition has unknown keys' do let(:config) do @@ -1381,22 +1530,22 @@ module Gitlab it "returns cache when defined globally" do config = YAML.dump({ - cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'key' }, - rspec: { - script: "rspec" - } - }) + cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'key' }, + rspec: { + script: "rspec" + } + }) config_processor = Gitlab::Ci::YamlProcessor.new(config).execute expect(config_processor.stage_builds_attributes("test").size).to eq(1) - expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq( + expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq([ paths: ["logs/", "binaries/"], untracked: true, key: 'key', policy: 'pull-push', when: 'on_success' - ) + ]) end it "returns cache when defined in default context" do @@ -1413,32 +1562,46 @@ module Gitlab config_processor = Gitlab::Ci::YamlProcessor.new(config).execute expect(config_processor.stage_builds_attributes("test").size).to eq(1) - expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq( + expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq([ paths: ["logs/", "binaries/"], untracked: true, key: { files: ['file'] }, policy: 'pull-push', when: 'on_success' - ) + ]) end - it 'returns cache key when defined in a job' do + it 'returns cache key/s when defined in a job' do config = YAML.dump({ - rspec: { - cache: { paths: ['logs/', 'binaries/'], untracked: true, key: 'key' }, - script: 'rspec' - } - }) + rspec: { + cache: [ + { paths: ['binaries/'], untracked: true, key: 'keya' }, + { paths: ['logs/', 'binaries/'], untracked: true, key: 'key' } + ], + script: 'rspec' + } + }) config_processor = Gitlab::Ci::YamlProcessor.new(config).execute expect(config_processor.stage_builds_attributes('test').size).to eq(1) expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq( - paths: ['logs/', 'binaries/'], - untracked: true, - key: 'key', - policy: 'pull-push', - when: 'on_success' + [ + { + paths: ['binaries/'], + untracked: true, + key: 'keya', + policy: 'pull-push', + when: 'on_success' + }, + { + paths: ['logs/', 'binaries/'], + untracked: true, + key: 'key', + policy: 'pull-push', + when: 'on_success' + } + ] ) end @@ -1446,10 +1609,10 @@ module Gitlab config = YAML.dump( rspec: { cache: { - paths: ['logs/', 'binaries/'], - untracked: true, - key: { files: ['file'] } - }, + paths: ['binaries/'], + untracked: true, + key: { files: ['file'] } + }, script: 'rspec' } ) @@ -1457,13 +1620,13 @@ module Gitlab config_processor = Gitlab::Ci::YamlProcessor.new(config).execute expect(config_processor.stage_builds_attributes('test').size).to eq(1) - expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq( - paths: ['logs/', 'binaries/'], + expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq([ + paths: ['binaries/'], untracked: true, key: { files: ['file'] }, policy: 'pull-push', when: 'on_success' - ) + ]) end it 'returns cache files with prefix' do @@ -1481,34 +1644,34 @@ module Gitlab config_processor = Gitlab::Ci::YamlProcessor.new(config).execute expect(config_processor.stage_builds_attributes('test').size).to eq(1) - expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq( + expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq([ paths: ['logs/', 'binaries/'], untracked: true, key: { files: ['file'], prefix: 'prefix' }, policy: 'pull-push', when: 'on_success' - ) + ]) end it "overwrite cache when defined for a job and globally" do config = YAML.dump({ - cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'global' }, - rspec: { - script: "rspec", - cache: { paths: ["test/"], untracked: false, key: 'local' } - } - }) + cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'global' }, + rspec: { + script: "rspec", + cache: { paths: ["test/"], untracked: false, key: 'local' } + } + }) config_processor = Gitlab::Ci::YamlProcessor.new(config).execute expect(config_processor.stage_builds_attributes("test").size).to eq(1) - expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq( + expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq([ paths: ["test/"], untracked: false, key: 'local', policy: 'pull-push', when: 'on_success' - ) + ]) end end @@ -1926,8 +2089,8 @@ module Gitlab only: { refs: %w[branches tags] }, options: { script: ["test"] }, needs_attributes: [ - { name: "build1", artifacts: true }, - { name: "build2", artifacts: true } + { name: "build1", artifacts: true, optional: false }, + { name: "build2", artifacts: true, optional: false } ], when: "on_success", allow_failure: false, @@ -1941,7 +2104,7 @@ module Gitlab let(:needs) do [ { job: 'parallel', artifacts: false }, - { job: 'build1', artifacts: true }, + { job: 'build1', artifacts: true, optional: true }, 'build2' ] end @@ -1968,10 +2131,10 @@ module Gitlab only: { refs: %w[branches tags] }, options: { script: ["test"] }, needs_attributes: [ - { name: "parallel 1/2", artifacts: false }, - { name: "parallel 2/2", artifacts: false }, - { name: "build1", artifacts: true }, - { name: "build2", artifacts: true } + { name: "parallel 1/2", artifacts: false, optional: false }, + { name: "parallel 2/2", artifacts: false, optional: false }, + { name: "build1", artifacts: true, optional: true }, + { name: "build2", artifacts: true, optional: false } ], when: "on_success", allow_failure: false, @@ -1993,8 +2156,8 @@ module Gitlab only: { refs: %w[branches tags] }, options: { script: ["test"] }, needs_attributes: [ - { name: "parallel 1/2", artifacts: true }, - { name: "parallel 2/2", artifacts: true } + { name: "parallel 1/2", artifacts: true, optional: false }, + { name: "parallel 2/2", artifacts: true, optional: false } ], when: "on_success", allow_failure: false, @@ -2022,10 +2185,10 @@ module Gitlab only: { refs: %w[branches tags] }, options: { script: ["test"] }, needs_attributes: [ - { name: "build1", artifacts: true }, - { name: "build2", artifacts: true }, - { name: "parallel 1/2", artifacts: true }, - { name: "parallel 2/2", artifacts: true } + { name: "build1", artifacts: true, optional: false }, + { name: "build2", artifacts: true, optional: false }, + { name: "parallel 1/2", artifacts: true, optional: false }, + { name: "parallel 2/2", artifacts: true, optional: false } ], when: "on_success", allow_failure: false, diff --git a/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb b/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb index 76578340f7b..2cdf95ea101 100644 --- a/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb +++ b/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb @@ -230,34 +230,13 @@ RSpec.describe Gitlab::CycleAnalytics::StageSummary do end context 'when `from` and `to` are within a day' do - context 'when query_deploymenys_via_finished_at_in_vsa feature flag is off' do - before do - stub_feature_flags(query_deploymenys_via_finished_at_in_vsa: false) - end - - it 'returns the number of deployments made on that day' do - freeze_time do - create(:deployment, :success, project: project) - options[:from] = options[:to] = Time.zone.now - - expect(subject).to eq('1') - end - end - end - - context 'when query_deploymenys_via_finished_at_in_vsa feature flag is off' do - before do - stub_feature_flags(query_deploymenys_via_finished_at_in_vsa: true) - end - - it 'returns the number of deployments made on that day' do - freeze_time do - create(:deployment, :success, project: project, finished_at: Time.zone.now) - options[:from] = Time.zone.now.at_beginning_of_day - options[:to] = Time.zone.now.at_end_of_day + it 'returns the number of deployments made on that day' do + freeze_time do + create(:deployment, :success, project: project, finished_at: Time.zone.now) + options[:from] = Time.zone.now.at_beginning_of_day + options[:to] = Time.zone.now.at_end_of_day - expect(subject).to eq('1') - end + expect(subject).to eq('1') end end end diff --git a/spec/lib/gitlab/data_builder/build_spec.rb b/spec/lib/gitlab/data_builder/build_spec.rb index 4242469b3db..ab1728414bb 100644 --- a/spec/lib/gitlab/data_builder/build_spec.rb +++ b/spec/lib/gitlab/data_builder/build_spec.rb @@ -38,6 +38,7 @@ RSpec.describe Gitlab::DataBuilder::Build do it { expect(data[:runner][:id]).to eq(build.runner.id) } it { expect(data[:runner][:tags]).to match_array(tag_names) } it { expect(data[:runner][:description]).to eq(build.runner.description) } + it { expect(data[:environment]).to be_nil } context 'commit author_url' do context 'when no commit present' do @@ -63,6 +64,13 @@ RSpec.describe Gitlab::DataBuilder::Build do expect(data[:commit][:author_url]).to eq(Gitlab::Routing.url_helpers.user_url(username: build.commit.author.username)) end end + + context 'with environment' do + let(:build) { create(:ci_build, :teardown_environment) } + + it { expect(data[:environment][:name]).to eq(build.expanded_environment_name) } + it { expect(data[:environment][:action]).to eq(build.environment_action) } + end end end end diff --git a/spec/lib/gitlab/data_builder/pipeline_spec.rb b/spec/lib/gitlab/data_builder/pipeline_spec.rb index fd7cadeb89e..cf04f560ceb 100644 --- a/spec/lib/gitlab/data_builder/pipeline_spec.rb +++ b/spec/lib/gitlab/data_builder/pipeline_spec.rb @@ -37,6 +37,7 @@ RSpec.describe Gitlab::DataBuilder::Pipeline do expect(build_data[:id]).to eq(build.id) expect(build_data[:status]).to eq(build.status) expect(build_data[:allow_failure]).to eq(build.allow_failure) + expect(build_data[:environment]).to be_nil expect(runner_data).to eq(nil) expect(project_data).to eq(project.hook_attrs(backward: false)) expect(data[:merge_request]).to be_nil @@ -115,5 +116,12 @@ RSpec.describe Gitlab::DataBuilder::Pipeline do expect(build_data[:id]).to eq(build.id) end end + + context 'build with environment' do + let!(:build) { create(:ci_build, :teardown_environment, pipeline: pipeline) } + + it { expect(build_data[:environment][:name]).to eq(build.expanded_environment_name) } + it { expect(build_data[:environment][:action]).to eq(build.environment_action) } + end end end diff --git a/spec/lib/gitlab/database/background_migration/batched_job_spec.rb b/spec/lib/gitlab/database/background_migration/batched_job_spec.rb new file mode 100644 index 00000000000..1020aafcf08 --- /dev/null +++ b/spec/lib/gitlab/database/background_migration/batched_job_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model do + it_behaves_like 'having unique enum values' + + describe 'associations' do + it { is_expected.to belong_to(:batched_migration).with_foreign_key(:batched_background_migration_id) } + end + + describe 'delegated batched_migration attributes' do + let(:batched_job) { build(:batched_background_migration_job) } + let(:batched_migration) { batched_job.batched_migration } + + describe '#migration_aborted?' do + before do + batched_migration.status = :aborted + end + + it 'returns the migration aborted?' do + expect(batched_job.migration_aborted?).to eq(batched_migration.aborted?) + end + end + + describe '#migration_job_class' do + it 'returns the migration job_class' do + expect(batched_job.migration_job_class).to eq(batched_migration.job_class) + end + end + + describe '#migration_table_name' do + it 'returns the migration table_name' do + expect(batched_job.migration_table_name).to eq(batched_migration.table_name) + end + end + + describe '#migration_column_name' do + it 'returns the migration column_name' do + expect(batched_job.migration_column_name).to eq(batched_migration.column_name) + end + end + + describe '#migration_job_arguments' do + it 'returns the migration job_arguments' do + expect(batched_job.migration_job_arguments).to eq(batched_migration.job_arguments) + end + end + end +end diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb new file mode 100644 index 00000000000..f4a939e7c1f --- /dev/null +++ b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :model do + it_behaves_like 'having unique enum values' + + describe 'associations' do + it { is_expected.to have_many(:batched_jobs).with_foreign_key(:batched_background_migration_id) } + + describe '#last_job' do + let!(:batched_migration) { create(:batched_background_migration) } + let!(:batched_job1) { create(:batched_background_migration_job, batched_migration: batched_migration) } + let!(:batched_job2) { create(:batched_background_migration_job, batched_migration: batched_migration) } + + it 'returns the most recent (in order of id) batched job' do + expect(batched_migration.last_job).to eq(batched_job2) + end + end + end + + describe '.queue_order' do + let!(:migration1) { create(:batched_background_migration) } + let!(:migration2) { create(:batched_background_migration) } + let!(:migration3) { create(:batched_background_migration) } + + it 'returns batched migrations ordered by their id' do + expect(described_class.queue_order.all).to eq([migration1, migration2, migration3]) + end + end + + describe '#interval_elapsed?' do + context 'when the migration has no last_job' do + let(:batched_migration) { build(:batched_background_migration) } + + it 'returns true' do + expect(batched_migration.interval_elapsed?).to eq(true) + end + end + + context 'when the migration has a last_job' do + let(:interval) { 2.minutes } + let(:batched_migration) { create(:batched_background_migration, interval: interval) } + + context 'when the last_job is less than an interval old' do + it 'returns false' do + freeze_time do + create(:batched_background_migration_job, + batched_migration: batched_migration, + created_at: Time.current - 1.minute) + + expect(batched_migration.interval_elapsed?).to eq(false) + end + end + end + + context 'when the last_job is exactly an interval old' do + it 'returns true' do + freeze_time do + create(:batched_background_migration_job, + batched_migration: batched_migration, + created_at: Time.current - 2.minutes) + + expect(batched_migration.interval_elapsed?).to eq(true) + end + end + end + + context 'when the last_job is more than an interval old' do + it 'returns true' do + freeze_time do + create(:batched_background_migration_job, + batched_migration: batched_migration, + created_at: Time.current - 3.minutes) + + expect(batched_migration.interval_elapsed?).to eq(true) + end + end + end + end + end + + describe '#create_batched_job!' do + let(:batched_migration) { create(:batched_background_migration) } + + it 'creates a batched_job with the correct batch configuration' do + batched_job = batched_migration.create_batched_job!(1, 5) + + expect(batched_job).to have_attributes( + min_value: 1, + max_value: 5, + batch_size: batched_migration.batch_size, + sub_batch_size: batched_migration.sub_batch_size) + end + end + + describe '#next_min_value' do + let!(:batched_migration) { create(:batched_background_migration) } + + context 'when a previous job exists' do + let!(:batched_job) { create(:batched_background_migration_job, batched_migration: batched_migration) } + + it 'returns the next value after the previous maximum' do + expect(batched_migration.next_min_value).to eq(batched_job.max_value + 1) + end + end + + context 'when a previous job does not exist' do + it 'returns the migration minimum value' do + expect(batched_migration.next_min_value).to eq(batched_migration.min_value) + end + end + end + + describe '#job_class' do + let(:job_class) { Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJob } + let(:batched_migration) { build(:batched_background_migration) } + + it 'returns the class of the job for the migration' do + expect(batched_migration.job_class).to eq(job_class) + end + end + + describe '#batch_class' do + let(:batch_class) { Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchingStrategy} + let(:batched_migration) { build(:batched_background_migration) } + + it 'returns the class of the batch strategy for the migration' do + expect(batched_migration.batch_class).to eq(batch_class) + end + end + + shared_examples_for 'an attr_writer that demodulizes assigned class names' do |attribute_name| + let(:batched_migration) { build(:batched_background_migration) } + + context 'when a module name exists' do + it 'removes the module name' do + batched_migration.public_send(:"#{attribute_name}=", '::Foo::Bar') + + expect(batched_migration[attribute_name]).to eq('Bar') + end + end + + context 'when a module name does not exist' do + it 'does not change the given class name' do + batched_migration.public_send(:"#{attribute_name}=", 'Bar') + + expect(batched_migration[attribute_name]).to eq('Bar') + end + end + end + + describe '#job_class_name=' do + it_behaves_like 'an attr_writer that demodulizes assigned class names', :job_class_name + end + + describe '#batch_class_name=' do + it_behaves_like 'an attr_writer that demodulizes assigned class names', :batch_class_name + end +end diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb new file mode 100644 index 00000000000..17cceb35ff7 --- /dev/null +++ b/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '#perform' do + let(:migration_wrapper) { described_class.new } + let(:job_class) { Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJob } + + let_it_be(:active_migration) { create(:batched_background_migration, :active, job_arguments: [:id, :other_id]) } + + let!(:job_record) { create(:batched_background_migration_job, batched_migration: active_migration) } + + it 'runs the migration job' do + expect_next_instance_of(job_class) do |job_instance| + expect(job_instance).to receive(:perform).with(1, 10, 'events', 'id', 1, 'id', 'other_id') + end + + migration_wrapper.perform(job_record) + end + + it 'updates the the tracking record in the database' do + expect(job_record).to receive(:update!).with(hash_including(attempts: 1, status: :running)).and_call_original + + freeze_time do + migration_wrapper.perform(job_record) + + reloaded_job_record = job_record.reload + + expect(reloaded_job_record).not_to be_pending + expect(reloaded_job_record.attempts).to eq(1) + expect(reloaded_job_record.started_at).to eq(Time.current) + end + end + + context 'when the migration job does not raise an error' do + it 'marks the tracking record as succeeded' do + expect_next_instance_of(job_class) do |job_instance| + expect(job_instance).to receive(:perform).with(1, 10, 'events', 'id', 1, 'id', 'other_id') + end + + freeze_time do + migration_wrapper.perform(job_record) + + reloaded_job_record = job_record.reload + + expect(reloaded_job_record).to be_succeeded + expect(reloaded_job_record.finished_at).to eq(Time.current) + end + end + end + + context 'when the migration job raises an error' do + it 'marks the tracking record as failed before raising the error' do + expect_next_instance_of(job_class) do |job_instance| + expect(job_instance).to receive(:perform) + .with(1, 10, 'events', 'id', 1, 'id', 'other_id') + .and_raise(RuntimeError, 'Something broke!') + end + + freeze_time do + expect { migration_wrapper.perform(job_record) }.to raise_error(RuntimeError, 'Something broke!') + + reloaded_job_record = job_record.reload + + expect(reloaded_job_record).to be_failed + expect(reloaded_job_record.finished_at).to eq(Time.current) + end + end + end +end diff --git a/spec/lib/gitlab/database/background_migration/scheduler_spec.rb b/spec/lib/gitlab/database/background_migration/scheduler_spec.rb new file mode 100644 index 00000000000..ba745acdf8a --- /dev/null +++ b/spec/lib/gitlab/database/background_migration/scheduler_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::BackgroundMigration::Scheduler, '#perform' do + let(:scheduler) { described_class.new } + + shared_examples_for 'it has no jobs to run' do + it 'does not create and run a migration job' do + test_wrapper = double('test wrapper') + + expect(test_wrapper).not_to receive(:perform) + + expect do + scheduler.perform(migration_wrapper: test_wrapper) + end.not_to change { Gitlab::Database::BackgroundMigration::BatchedJob.count } + end + end + + context 'when there are no active migrations' do + let!(:migration) { create(:batched_background_migration, :finished) } + + it_behaves_like 'it has no jobs to run' + end + + shared_examples_for 'it has completed the migration' do + it 'marks the migration as finished' do + relation = Gitlab::Database::BackgroundMigration::BatchedMigration.finished.where(id: first_migration.id) + + expect { scheduler.perform }.to change { relation.count }.by(1) + end + end + + context 'when there are active migrations' do + let!(:first_migration) { create(:batched_background_migration, :active, batch_size: 2) } + let!(:last_migration) { create(:batched_background_migration, :active) } + + let(:job_relation) do + Gitlab::Database::BackgroundMigration::BatchedJob.where(batched_background_migration_id: first_migration.id) + end + + context 'when the migration interval has not elapsed' do + before do + expect_next_found_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigration) do |migration| + expect(migration).to receive(:interval_elapsed?).and_return(false) + end + end + + it_behaves_like 'it has no jobs to run' + end + + context 'when the interval has elapsed' do + before do + expect_next_found_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigration) do |migration| + expect(migration).to receive(:interval_elapsed?).and_return(true) + end + end + + context 'when the first migration has no previous jobs' do + context 'when the migration has batches to process' do + let!(:event1) { create(:event) } + let!(:event2) { create(:event) } + let!(:event3) { create(:event) } + + it 'runs the job for the first batch' do + first_migration.update!(min_value: event1.id, max_value: event3.id) + + expect_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper) do |wrapper| + expect(wrapper).to receive(:perform).and_wrap_original do |_, job_record| + expect(job_record).to eq(job_relation.first) + end + end + + expect { scheduler.perform }.to change { job_relation.count }.by(1) + + expect(job_relation.first).to have_attributes( + min_value: event1.id, + max_value: event2.id, + batch_size: first_migration.batch_size, + sub_batch_size: first_migration.sub_batch_size) + end + end + + context 'when the migration has no batches to process' do + it_behaves_like 'it has no jobs to run' + it_behaves_like 'it has completed the migration' + end + end + + context 'when the first migration has previous jobs' do + let!(:event1) { create(:event) } + let!(:event2) { create(:event) } + let!(:event3) { create(:event) } + + let!(:previous_job) do + create(:batched_background_migration_job, + batched_migration: first_migration, + min_value: event1.id, + max_value: event2.id, + batch_size: 2, + sub_batch_size: 1) + end + + context 'when the migration is ready to process another job' do + it 'runs the migration job for the next batch' do + first_migration.update!(min_value: event1.id, max_value: event3.id) + + expect_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper) do |wrapper| + expect(wrapper).to receive(:perform).and_wrap_original do |_, job_record| + expect(job_record).to eq(job_relation.last) + end + end + + expect { scheduler.perform }.to change { job_relation.count }.by(1) + + expect(job_relation.last).to have_attributes( + min_value: event3.id, + max_value: event3.id, + batch_size: first_migration.batch_size, + sub_batch_size: first_migration.sub_batch_size) + end + end + + context 'when the migration has no batches remaining' do + let!(:final_job) do + create(:batched_background_migration_job, + batched_migration: first_migration, + min_value: event3.id, + max_value: event3.id, + batch_size: 2, + sub_batch_size: 1) + end + + it_behaves_like 'it has no jobs to run' + it_behaves_like 'it has completed the migration' + end + end + + context 'when the bounds of the next batch exceed the migration maximum value' do + let!(:events) { create_list(:event, 3) } + let(:event1) { events[0] } + let(:event2) { events[1] } + + context 'when the batch maximum exceeds the migration maximum' do + it 'clamps the batch maximum to the migration maximum' do + first_migration.update!(batch_size: 5, min_value: event1.id, max_value: event2.id) + + expect_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper) do |wrapper| + expect(wrapper).to receive(:perform) + end + + expect { scheduler.perform }.to change { job_relation.count }.by(1) + + expect(job_relation.first).to have_attributes( + min_value: event1.id, + max_value: event2.id, + batch_size: first_migration.batch_size, + sub_batch_size: first_migration.sub_batch_size) + end + end + + context 'when the batch minimum exceeds the migration maximum' do + let!(:previous_job) do + create(:batched_background_migration_job, + batched_migration: first_migration, + min_value: event1.id, + max_value: event2.id, + batch_size: 5, + sub_batch_size: 1) + end + + before do + first_migration.update!(batch_size: 5, min_value: 1, max_value: event2.id) + end + + it_behaves_like 'it has no jobs to run' + it_behaves_like 'it has completed the migration' + end + end + end + end +end diff --git a/spec/lib/gitlab/database/bulk_update_spec.rb b/spec/lib/gitlab/database/bulk_update_spec.rb index f2a7d6e69d8..dbafada26ca 100644 --- a/spec/lib/gitlab/database/bulk_update_spec.rb +++ b/spec/lib/gitlab/database/bulk_update_spec.rb @@ -13,8 +13,8 @@ RSpec.describe Gitlab::Database::BulkUpdate do i_a, i_b = create_list(:issue, 2) { - i_a => { title: 'Issue a' }, - i_b => { title: 'Issue b' } + i_a => { title: 'Issue a' }, + i_b => { title: 'Issue b' } } end @@ -51,7 +51,7 @@ RSpec.describe Gitlab::Database::BulkUpdate do it 'is possible to update all objects in a single query' do users = create_list(:user, 3) - mapping = users.zip(%w(foo bar baz)).to_h do |u, name| + mapping = users.zip(%w[foo bar baz]).to_h do |u, name| [u, { username: name, admin: true }] end @@ -61,13 +61,13 @@ RSpec.describe Gitlab::Database::BulkUpdate do # We have optimistically updated the values expect(users).to all(be_admin) - expect(users.map(&:username)).to eq(%w(foo bar baz)) + expect(users.map(&:username)).to eq(%w[foo bar baz]) users.each(&:reset) # The values are correct on reset expect(users).to all(be_admin) - expect(users.map(&:username)).to eq(%w(foo bar baz)) + expect(users.map(&:username)).to eq(%w[foo bar baz]) end it 'is possible to update heterogeneous sets' do @@ -79,8 +79,8 @@ RSpec.describe Gitlab::Database::BulkUpdate do mapping = { mr_a => { title: 'MR a' }, - i_a => { title: 'Issue a' }, - i_b => { title: 'Issue b' } + i_a => { title: 'Issue a' }, + i_b => { title: 'Issue b' } } expect do @@ -99,8 +99,8 @@ RSpec.describe Gitlab::Database::BulkUpdate do i_a, i_b = create_list(:issue, 2) mapping = { - i_a => { title: 'Issue a' }, - i_b => { title: 'Issue b' } + i_a => { title: 'Issue a' }, + i_b => { title: 'Issue b' } } described_class.execute(%i[title], mapping) @@ -113,23 +113,19 @@ RSpec.describe Gitlab::Database::BulkUpdate do include_examples 'basic functionality' context 'when prepared statements are configured differently to the normal test environment' do - # rubocop: disable RSpec/LeakyConstantDeclaration - # This cop is disabled because you cannot call establish_connection on - # an anonymous class. - class ActiveRecordBasePreparedStatementsInverted < ActiveRecord::Base - def self.abstract_class? - true # So it gets its own connection + before do + klass = Class.new(ActiveRecord::Base) do + def self.abstract_class? + true # So it gets its own connection + end end - end - # rubocop: enable RSpec/LeakyConstantDeclaration - before_all do + stub_const('ActiveRecordBasePreparedStatementsInverted', klass) + c = ActiveRecord::Base.connection.instance_variable_get(:@config) inverted = c.merge(prepared_statements: !ActiveRecord::Base.connection.prepared_statements) ActiveRecordBasePreparedStatementsInverted.establish_connection(inverted) - end - before do allow(ActiveRecord::Base).to receive(:connection_specification_name) .and_return(ActiveRecordBasePreparedStatementsInverted.connection_specification_name) end diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 6de7fc3a50e..9178707a3d0 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -180,6 +180,32 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end end + context 'when with_lock_retries re-runs the block' do + it 'only creates constraint for unique definitions' do + expected_sql = <<~SQL + ALTER TABLE "#{table_name}"\nADD CONSTRAINT "check_cda6f69506" CHECK (char_length("name") <= 255) + SQL + + expect(model).to receive(:create_table).twice.and_call_original + + expect(model).to receive(:execute).with(expected_sql).and_raise(ActiveRecord::LockWaitTimeout) + expect(model).to receive(:execute).with(expected_sql).and_call_original + + model.create_table_with_constraints table_name do |t| + t.timestamps_with_timezone + t.integer :some_id, null: false + t.boolean :active, null: false, default: true + t.text :name + + t.text_limit :name, 255 + end + + expect_table_columns_to_match(column_attributes, table_name) + + expect_check_constraint(table_name, 'check_cda6f69506', 'char_length(name) <= 255') + end + end + context 'when constraints are given invalid names' do let(:expected_max_length) { described_class::MAX_IDENTIFIER_NAME_LENGTH } let(:expected_error_message) { "The maximum allowed constraint name is #{expected_max_length} characters" } @@ -1720,7 +1746,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do .with( 2.minutes, 'CopyColumnUsingBackgroundMigrationJob', - [event.id, event.id, :events, :id, :id, 'id_convert_to_bigint', 100] + [event.id, event.id, :events, :id, 100, :id, 'id_convert_to_bigint'] ) expect(Gitlab::BackgroundMigration) diff --git a/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb b/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb index 3e8563376ce..e25e4af2e86 100644 --- a/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb @@ -21,7 +21,7 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do context 'with enough rows to bulk queue jobs more than once' do before do - stub_const('Gitlab::Database::Migrations::BackgroundMigrationHelpers::BACKGROUND_MIGRATION_JOB_BUFFER_SIZE', 1) + stub_const('Gitlab::Database::Migrations::BackgroundMigrationHelpers::JOB_BUFFER_SIZE', 1) end it 'queues jobs correctly' do @@ -262,6 +262,120 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do end end + describe '#queue_batched_background_migration' do + it 'creates the database record for the migration' do + expect do + model.queue_batched_background_migration( + 'MyJobClass', + :projects, + :id, + job_interval: 5.minutes, + batch_min_value: 5, + batch_max_value: 1000, + batch_class_name: 'MyBatchClass', + batch_size: 100, + sub_batch_size: 10) + end.to change { Gitlab::Database::BackgroundMigration::BatchedMigration.count }.by(1) + + expect(Gitlab::Database::BackgroundMigration::BatchedMigration.last).to have_attributes( + job_class_name: 'MyJobClass', + table_name: 'projects', + column_name: 'id', + interval: 300, + min_value: 5, + max_value: 1000, + batch_class_name: 'MyBatchClass', + batch_size: 100, + sub_batch_size: 10, + job_arguments: %w[], + status: 'active') + end + + context 'when the job interval is lower than the minimum' do + let(:minimum_delay) { described_class::BATCH_MIN_DELAY } + + it 'sets the job interval to the minimum value' do + expect do + model.queue_batched_background_migration('MyJobClass', :events, :id, job_interval: minimum_delay - 1.minute) + end.to change { Gitlab::Database::BackgroundMigration::BatchedMigration.count }.by(1) + + created_migration = Gitlab::Database::BackgroundMigration::BatchedMigration.last + + expect(created_migration.interval).to eq(minimum_delay) + end + end + + context 'when additional arguments are passed to the method' do + it 'saves the arguments on the database record' do + expect do + model.queue_batched_background_migration( + 'MyJobClass', + :projects, + :id, + 'my', + 'arguments', + job_interval: 5.minutes, + batch_max_value: 1000) + end.to change { Gitlab::Database::BackgroundMigration::BatchedMigration.count }.by(1) + + expect(Gitlab::Database::BackgroundMigration::BatchedMigration.last).to have_attributes( + job_class_name: 'MyJobClass', + table_name: 'projects', + column_name: 'id', + interval: 300, + min_value: 1, + max_value: 1000, + job_arguments: %w[my arguments]) + end + end + + context 'when the max_value is not given' do + context 'when records exist in the database' do + let!(:event1) { create(:event) } + let!(:event2) { create(:event) } + let!(:event3) { create(:event) } + + it 'creates the record with the current max value' do + expect do + model.queue_batched_background_migration('MyJobClass', :events, :id, job_interval: 5.minutes) + end.to change { Gitlab::Database::BackgroundMigration::BatchedMigration.count }.by(1) + + created_migration = Gitlab::Database::BackgroundMigration::BatchedMigration.last + + expect(created_migration.max_value).to eq(event3.id) + end + + it 'creates the record with an active status' do + expect do + model.queue_batched_background_migration('MyJobClass', :events, :id, job_interval: 5.minutes) + end.to change { Gitlab::Database::BackgroundMigration::BatchedMigration.count }.by(1) + + expect(Gitlab::Database::BackgroundMigration::BatchedMigration.last).to be_active + end + end + + context 'when the database is empty' do + it 'sets the max value to the min value' do + expect do + model.queue_batched_background_migration('MyJobClass', :events, :id, job_interval: 5.minutes) + end.to change { Gitlab::Database::BackgroundMigration::BatchedMigration.count }.by(1) + + created_migration = Gitlab::Database::BackgroundMigration::BatchedMigration.last + + expect(created_migration.max_value).to eq(created_migration.min_value) + end + + it 'creates the record with a finished status' do + expect do + model.queue_batched_background_migration('MyJobClass', :projects, :id, job_interval: 5.minutes) + end.to change { Gitlab::Database::BackgroundMigration::BatchedMigration.count }.by(1) + + expect(Gitlab::Database::BackgroundMigration::BatchedMigration.last).to be_finished + end + end + end + end + describe '#migrate_async' do it 'calls BackgroundMigrationWorker.perform_async' do expect(BackgroundMigrationWorker).to receive(:perform_async).with("Class", "hello", "world") diff --git a/spec/lib/gitlab/database/migrations/observers/query_statistics_spec.rb b/spec/lib/gitlab/database/migrations/observers/query_statistics_spec.rb new file mode 100644 index 00000000000..a3b03050b33 --- /dev/null +++ b/spec/lib/gitlab/database/migrations/observers/query_statistics_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Gitlab::Database::Migrations::Observers::QueryStatistics do + subject { described_class.new } + + let(:connection) { ActiveRecord::Base.connection } + + def mock_pgss(enabled: true) + if enabled + allow(subject).to receive(:function_exists?).with(:pg_stat_statements_reset).and_return(true) + allow(connection).to receive(:view_exists?).with(:pg_stat_statements).and_return(true) + else + allow(subject).to receive(:function_exists?).with(:pg_stat_statements_reset).and_return(false) + allow(connection).to receive(:view_exists?).with(:pg_stat_statements).and_return(false) + end + end + + describe '#before' do + context 'with pgss available' do + it 'resets pg_stat_statements' do + mock_pgss(enabled: true) + expect(connection).to receive(:execute).with('select pg_stat_statements_reset()').once + + subject.before + end + end + + context 'without pgss available' do + it 'executes nothing' do + mock_pgss(enabled: false) + expect(connection).not_to receive(:execute) + + subject.before + end + end + end + + describe '#record' do + let(:observation) { Gitlab::Database::Migrations::Observation.new } + let(:result) { double } + let(:pgss_query) do + <<~SQL + SELECT query, calls, total_time, max_time, mean_time, rows + FROM pg_stat_statements + ORDER BY total_time DESC + SQL + end + + context 'with pgss available' do + it 'fetches data from pg_stat_statements and stores on the observation' do + mock_pgss(enabled: true) + expect(connection).to receive(:execute).with(pgss_query).once.and_return(result) + + expect { subject.record(observation) }.to change { observation.query_statistics }.from(nil).to(result) + end + end + + context 'without pgss available' do + it 'executes nothing' do + mock_pgss(enabled: false) + expect(connection).not_to receive(:execute) + + expect { subject.record(observation) }.not_to change { observation.query_statistics } + end + end + end +end diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb index 76b1be1e497..757da2d9092 100644 --- a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb +++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb @@ -81,7 +81,7 @@ RSpec.describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase, : end describe '#rename_path_for_routable' do - context 'for namespaces' do + context 'for personal namespaces' do let(:namespace) { create(:namespace, path: 'the-path') } it "renames namespaces called the-path" do @@ -119,13 +119,16 @@ RSpec.describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase, : expect(project.route.reload.path).to eq('the-path-but-not-really/the-project') end + end - context "the-path namespace -> subgroup -> the-path0 project" do + context 'for groups' do + context "the-path group -> subgroup -> the-path0 project" do it "updates the route of the project correctly" do - subgroup = create(:group, path: "subgroup", parent: namespace) + group = create(:group, path: 'the-path') + subgroup = create(:group, path: "subgroup", parent: group) project = create(:project, :repository, path: "the-path0", namespace: subgroup) - subject.rename_path_for_routable(migration_namespace(namespace)) + subject.rename_path_for_routable(migration_namespace(group)) expect(project.route.reload.path).to eq("the-path0/subgroup/the-path0") end @@ -158,23 +161,27 @@ RSpec.describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase, : end describe '#perform_rename' do - describe 'for namespaces' do - let(:namespace) { create(:namespace, path: 'the-path') } - + context 'for personal namespaces' do it 'renames the path' do + namespace = create(:namespace, path: 'the-path') + subject.perform_rename(migration_namespace(namespace), 'the-path', 'renamed') expect(namespace.reload.path).to eq('renamed') + expect(namespace.reload.route.path).to eq('renamed') end + end - it 'renames all the routes for the namespace' do - child = create(:group, path: 'child', parent: namespace) + context 'for groups' do + it 'renames all the routes for the group' do + group = create(:group, path: 'the-path') + child = create(:group, path: 'child', parent: group) project = create(:project, :repository, namespace: child, path: 'the-project') - other_one = create(:namespace, path: 'the-path-is-similar') + other_one = create(:group, path: 'the-path-is-similar') - subject.perform_rename(migration_namespace(namespace), 'the-path', 'renamed') + subject.perform_rename(migration_namespace(group), 'the-path', 'renamed') - expect(namespace.reload.route.path).to eq('renamed') + expect(group.reload.route.path).to eq('renamed') expect(child.reload.route.path).to eq('renamed/child') expect(project.reload.route.path).to eq('renamed/child/the-project') expect(other_one.reload.route.path).to eq('the-path-is-similar') diff --git a/spec/lib/gitlab/database/similarity_score_spec.rb b/spec/lib/gitlab/database/similarity_score_spec.rb index cf75e5a72d9..b7b66494390 100644 --- a/spec/lib/gitlab/database/similarity_score_spec.rb +++ b/spec/lib/gitlab/database/similarity_score_spec.rb @@ -71,7 +71,7 @@ RSpec.describe Gitlab::Database::SimilarityScore do let(:search) { 'xyz' } it 'results have 0 similarity score' do - expect(query_result.map { |row| row['similarity'] }).to all(eq(0)) + expect(query_result.map { |row| row['similarity'].to_f }).to all(eq(0)) end end end diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb index 3175040167b..1553a989dba 100644 --- a/spec/lib/gitlab/database_spec.rb +++ b/spec/lib/gitlab/database_spec.rb @@ -441,4 +441,112 @@ RSpec.describe Gitlab::Database do end end end + + describe 'ActiveRecordBaseTransactionMetrics' do + def subscribe_events + events = [] + + begin + subscriber = ActiveSupport::Notifications.subscribe('transaction.active_record') do |e| + events << e + end + + yield + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + + events + end + + context 'without a transaction block' do + it 'does not publish a transaction event' do + events = subscribe_events do + User.first + end + + expect(events).to be_empty + end + end + + context 'within a transaction block' do + it 'publishes a transaction event' do + events = subscribe_events do + ActiveRecord::Base.transaction do + User.first + end + end + + expect(events.length).to be(1) + + event = events.first + expect(event).not_to be_nil + expect(event.duration).to be > 0.0 + expect(event.payload).to a_hash_including( + connection: be_a(ActiveRecord::ConnectionAdapters::AbstractAdapter) + ) + end + end + + context 'within an empty transaction block' do + it 'publishes a transaction event' do + events = subscribe_events do + ActiveRecord::Base.transaction {} + end + + expect(events.length).to be(1) + + event = events.first + expect(event).not_to be_nil + expect(event.duration).to be > 0.0 + expect(event.payload).to a_hash_including( + connection: be_a(ActiveRecord::ConnectionAdapters::AbstractAdapter) + ) + end + end + + context 'within a nested transaction block' do + it 'publishes multiple transaction events' do + events = subscribe_events do + ActiveRecord::Base.transaction do + ActiveRecord::Base.transaction do + ActiveRecord::Base.transaction do + User.first + end + end + end + end + + expect(events.length).to be(3) + + events.each do |event| + expect(event).not_to be_nil + expect(event.duration).to be > 0.0 + expect(event.payload).to a_hash_including( + connection: be_a(ActiveRecord::ConnectionAdapters::AbstractAdapter) + ) + end + end + end + + context 'within a cancelled transaction block' do + it 'publishes multiple transaction events' do + events = subscribe_events do + ActiveRecord::Base.transaction do + User.first + raise ActiveRecord::Rollback + end + end + + expect(events.length).to be(1) + + event = events.first + expect(event).not_to be_nil + expect(event.duration).to be > 0.0 + expect(event.payload).to a_hash_including( + connection: be_a(ActiveRecord::ConnectionAdapters::AbstractAdapter) + ) + end + end + end end diff --git a/spec/lib/gitlab/diff/highlight_cache_spec.rb b/spec/lib/gitlab/diff/highlight_cache_spec.rb index 94717152488..d26bc5fc9a8 100644 --- a/spec/lib/gitlab/diff/highlight_cache_spec.rb +++ b/spec/lib/gitlab/diff/highlight_cache_spec.rb @@ -237,17 +237,17 @@ RSpec.describe Gitlab::Diff::HighlightCache, :clean_gitlab_redis_cache do describe '#key' do subject { cache.key } - it 'returns the next version of the cache' do - is_expected.to start_with("highlighted-diff-files:#{cache.diffable.cache_key}:2") + it 'returns cache key' do + is_expected.to eq("highlighted-diff-files:#{cache.diffable.cache_key}:2:#{cache.diff_options}:true") end context 'when feature flag is disabled' do before do - stub_feature_flags(improved_merge_diff_highlighting: false) + stub_feature_flags(introduce_marker_ranges: false) end it 'returns the original version of the cache' do - is_expected.to start_with("highlighted-diff-files:#{cache.diffable.cache_key}:1") + is_expected.to eq("highlighted-diff-files:#{cache.diffable.cache_key}:2:#{cache.diff_options}:false") end end end diff --git a/spec/lib/gitlab/diff/highlight_spec.rb b/spec/lib/gitlab/diff/highlight_spec.rb index 283437e7fbd..e613674af3a 100644 --- a/spec/lib/gitlab/diff/highlight_spec.rb +++ b/spec/lib/gitlab/diff/highlight_spec.rb @@ -50,11 +50,23 @@ RSpec.describe Gitlab::Diff::Highlight do end it 'highlights and marks added lines' do - code = %Q{+<span id="LC9" class="line" lang="ruby"> <span class="k">raise</span> <span class="no"><span class="idiff left">RuntimeError</span></span><span class="p"><span class="idiff">,</span></span><span class="idiff right"> </span><span class="s2">"System commands must be given as an array of strings"</span></span>\n} + code = %Q{+<span id="LC9" class="line" lang="ruby"> <span class="k">raise</span> <span class="no"><span class="idiff left addition">RuntimeError</span></span><span class="p"><span class="idiff addition">,</span></span><span class="idiff right addition"> </span><span class="s2">"System commands must be given as an array of strings"</span></span>\n} expect(subject[5].rich_text).to eq(code) end + context 'when introduce_marker_ranges is false' do + before do + stub_feature_flags(introduce_marker_ranges: false) + end + + it 'keeps the old bevavior (without mode classes)' do + code = %Q{+<span id="LC9" class="line" lang="ruby"> <span class="k">raise</span> <span class="no"><span class="idiff left">RuntimeError</span></span><span class="p"><span class="idiff">,</span></span><span class="idiff right"> </span><span class="s2">"System commands must be given as an array of strings"</span></span>\n} + + expect(subject[5].rich_text).to eq(code) + end + end + context 'when no diff_refs' do before do allow(diff_file).to receive(:diff_refs).and_return(nil) @@ -93,7 +105,7 @@ RSpec.describe Gitlab::Diff::Highlight do end it 'marks added lines' do - code = %q{+ raise <span class="idiff left right">RuntimeError, </span>"System commands must be given as an array of strings"} + code = %q{+ raise <span class="idiff left right addition">RuntimeError, </span>"System commands must be given as an array of strings"} expect(subject[5].rich_text).to eq(code) expect(subject[5].rich_text).to be_html_safe diff --git a/spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb b/spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb index 60f7f3a103f..3670074cc21 100644 --- a/spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb +++ b/spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb @@ -5,8 +5,8 @@ require 'spec_helper' RSpec.describe Gitlab::Diff::InlineDiffMarkdownMarker do describe '#mark' do let(:raw) { "abc 'def'" } - let(:inline_diffs) { [2..5] } - let(:subject) { described_class.new(raw).mark(inline_diffs, mode: :deletion) } + let(:inline_diffs) { [Gitlab::MarkerRange.new(2, 5, mode: Gitlab::MarkerRange::DELETION)] } + let(:subject) { described_class.new(raw).mark(inline_diffs) } it 'does not escape html etities and marks the range' do expect(subject).to eq("ab{-c 'd-}ef'") diff --git a/spec/lib/gitlab/diff/inline_diff_spec.rb b/spec/lib/gitlab/diff/inline_diff_spec.rb index dce655d5690..714b5d813c4 100644 --- a/spec/lib/gitlab/diff/inline_diff_spec.rb +++ b/spec/lib/gitlab/diff/inline_diff_spec.rb @@ -52,17 +52,6 @@ RSpec.describe Gitlab::Diff::InlineDiff do expect(subject[0]).to eq([3..6]) expect(subject[1]).to eq([3..3, 17..22]) end - - context 'when feature flag is disabled' do - before do - stub_feature_flags(improved_merge_diff_highlighting: false) - end - - it 'finds all inline diffs' do - expect(subject[0]).to eq([3..19]) - expect(subject[1]).to eq([3..22]) - end - end end end diff --git a/spec/lib/gitlab/diff/pair_selector_spec.rb b/spec/lib/gitlab/diff/pair_selector_spec.rb new file mode 100644 index 00000000000..da5707bc377 --- /dev/null +++ b/spec/lib/gitlab/diff/pair_selector_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Diff::PairSelector do + subject(:selector) { described_class.new(lines) } + + describe '#to_a' do + subject { selector.to_a } + + let(:lines) { diff.lines } + + let(:diff) do + <<-EOF.strip_heredoc + class Test # 0 + - def initialize(test = true) # 1 + + def initialize(test = false) # 2 + @test = test # 3 + - if true # 4 + - @foo = "bar" # 5 + + unless false # 6 + + @foo = "baz" # 7 + end + end + end + EOF + end + + it 'finds all pairs' do + is_expected.to match_array([[1, 2], [4, 6], [5, 7]]) + end + + context 'when there are empty lines' do + let(:lines) { ['- bar', '+ baz', ''] } + + it { expect { subject }.not_to raise_error } + end + + context 'when there are only removals' do + let(:diff) do + <<-EOF.strip_heredoc + - class Test + - def initialize(test = true) + - end + - end + EOF + end + + it 'returns empty collection' do + is_expected.to eq([]) + end + end + + context 'when there are only additions' do + let(:diff) do + <<-EOF.strip_heredoc + + class Test + + def initialize(test = true) + + end + + end + EOF + end + + it 'returns empty collection' do + is_expected.to eq([]) + end + end + + context 'when there are no changes' do + let(:diff) do + <<-EOF.strip_heredoc + class Test + def initialize(test = true) + end + end + EOF + end + + it 'returns empty collection' do + is_expected.to eq([]) + end + end + end +end diff --git a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb index eb11c051adc..7436765e8ee 100644 --- a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb @@ -36,7 +36,7 @@ RSpec.describe Gitlab::Email::Handler::ServiceDeskHandler do expect(new_issue.author).to eql(User.support_bot) expect(new_issue.confidential?).to be true expect(new_issue.all_references.all).to be_empty - expect(new_issue.title).to eq("Service Desk (from jake@adventuretime.ooo): The message subject! @all") + expect(new_issue.title).to eq("The message subject! @all") expect(new_issue.description).to eq(expected_description.strip) end diff --git a/spec/lib/gitlab/error_tracking/context_payload_generator_spec.rb b/spec/lib/gitlab/error_tracking/context_payload_generator_spec.rb new file mode 100644 index 00000000000..0e72dd7ec5e --- /dev/null +++ b/spec/lib/gitlab/error_tracking/context_payload_generator_spec.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'rspec-parameterized' + +RSpec.describe Gitlab::ErrorTracking::ContextPayloadGenerator do + subject(:generator) { described_class.new } + + let(:extra) do + { + some_other_info: 'info', + issue_url: 'http://gitlab.com/gitlab-org/gitlab-foss/-/issues/1' + } + end + + let(:exception) { StandardError.new("Dummy exception") } + + before do + allow(Labkit::Correlation::CorrelationId).to receive(:current_id).and_return('cid') + allow(I18n).to receive(:locale).and_return('en') + end + + context 'user metadata' do + let(:user) { create(:user) } + + it 'appends user metadata to the payload' do + payload = {} + + Gitlab::ApplicationContext.with_context(user: user) do + payload = generator.generate(exception, extra) + end + + expect(payload[:user]).to eql( + username: user.username + ) + end + end + + context 'tags metadata' do + context 'when the GITLAB_SENTRY_EXTRA_TAGS env is not set' do + before do + stub_env('GITLAB_SENTRY_EXTRA_TAGS', nil) + end + + it 'does not log into AppLogger' do + expect(Gitlab::AppLogger).not_to receive(:debug) + + generator.generate(exception, extra) + end + + it 'does not send any extra tags' do + payload = {} + + Gitlab::ApplicationContext.with_context(feature_category: 'feature_a') do + payload = generator.generate(exception, extra) + end + + expect(payload[:tags]).to eql( + correlation_id: 'cid', + locale: 'en', + program: 'test', + feature_category: 'feature_a' + ) + end + end + + context 'when the GITLAB_SENTRY_EXTRA_TAGS env is a JSON hash' do + it 'includes those tags in all events' do + stub_env('GITLAB_SENTRY_EXTRA_TAGS', { foo: 'bar', baz: 'quux' }.to_json) + payload = {} + + Gitlab::ApplicationContext.with_context(feature_category: 'feature_a') do + payload = generator.generate(exception, extra) + end + + expect(payload[:tags]).to eql( + correlation_id: 'cid', + locale: 'en', + program: 'test', + feature_category: 'feature_a', + 'foo' => 'bar', + 'baz' => 'quux' + ) + end + + it 'does not log into AppLogger' do + expect(Gitlab::AppLogger).not_to receive(:debug) + + generator.generate(exception, extra) + end + end + + context 'when the GITLAB_SENTRY_EXTRA_TAGS env is not a JSON hash' do + using RSpec::Parameterized::TableSyntax + + where(:env_var, :error) do + { foo: 'bar', baz: 'quux' }.inspect | 'JSON::ParserError' + [].to_json | 'NoMethodError' + [%w[foo bar]].to_json | 'NoMethodError' + %w[foo bar].to_json | 'NoMethodError' + '"string"' | 'NoMethodError' + end + + with_them do + before do + stub_env('GITLAB_SENTRY_EXTRA_TAGS', env_var) + end + + it 'logs into AppLogger' do + expect(Gitlab::AppLogger).to receive(:debug).with(a_string_matching(error)) + + generator.generate({}) + end + + it 'does not include any extra tags' do + payload = {} + + Gitlab::ApplicationContext.with_context(feature_category: 'feature_a') do + payload = generator.generate(exception, extra) + end + + expect(payload[:tags]).to eql( + correlation_id: 'cid', + locale: 'en', + program: 'test', + feature_category: 'feature_a' + ) + end + end + end + end + + context 'extra metadata' do + it 'appends extra metadata to the payload' do + payload = generator.generate(exception, extra) + + expect(payload[:extra]).to eql( + some_other_info: 'info', + issue_url: 'http://gitlab.com/gitlab-org/gitlab-foss/-/issues/1' + ) + end + + it 'appends exception embedded extra metadata to the payload' do + allow(exception).to receive(:sentry_extra_data).and_return( + some_other_info: 'another_info', + mr_url: 'https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/1' + ) + + payload = generator.generate(exception, extra) + + expect(payload[:extra]).to eql( + some_other_info: 'another_info', + issue_url: 'http://gitlab.com/gitlab-org/gitlab-foss/-/issues/1', + mr_url: 'https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/1' + ) + end + + it 'filters sensitive extra info' do + extra[:my_token] = '456' + allow(exception).to receive(:sentry_extra_data).and_return( + mr_url: 'https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/1', + another_token: '1234' + ) + + payload = generator.generate(exception, extra) + + expect(payload[:extra]).to eql( + some_other_info: 'info', + issue_url: 'http://gitlab.com/gitlab-org/gitlab-foss/-/issues/1', + mr_url: 'https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/1', + my_token: '[FILTERED]', + another_token: '[FILTERED]' + ) + end + end +end diff --git a/spec/lib/gitlab/error_tracking/log_formatter_spec.rb b/spec/lib/gitlab/error_tracking/log_formatter_spec.rb new file mode 100644 index 00000000000..188ccd000a1 --- /dev/null +++ b/spec/lib/gitlab/error_tracking/log_formatter_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::ErrorTracking::LogFormatter do + let(:exception) { StandardError.new('boom') } + let(:context_payload) do + { + server: 'local-hostname-of-the-server', + user: { + ip_address: '127.0.0.1', + username: 'root' + }, + tags: { + locale: 'en', + feature_category: 'category_a' + }, + extra: { + some_other_info: 'other_info', + sidekiq: { + 'class' => 'HelloWorker', + 'args' => ['senstive string', 1, 2], + 'another_field' => 'field' + } + } + } + end + + before do + Raven.context.user[:user_flag] = 'flag' + Raven.context.tags[:shard] = 'catchall' + Raven.context.extra[:some_info] = 'info' + + allow(exception).to receive(:backtrace).and_return( + [ + 'lib/gitlab/file_a.rb:1', + 'lib/gitlab/file_b.rb:2' + ] + ) + end + + after do + ::Raven::Context.clear! + end + + it 'appends error-related log fields and filters sensitive Sidekiq arguments' do + payload = described_class.new.generate_log(exception, context_payload) + + expect(payload).to eql( + 'exception.class' => 'StandardError', + 'exception.message' => 'boom', + 'exception.backtrace' => [ + 'lib/gitlab/file_a.rb:1', + 'lib/gitlab/file_b.rb:2' + ], + 'user.ip_address' => '127.0.0.1', + 'user.username' => 'root', + 'user.user_flag' => 'flag', + 'tags.locale' => 'en', + 'tags.feature_category' => 'category_a', + 'tags.shard' => 'catchall', + 'extra.some_other_info' => 'other_info', + 'extra.some_info' => 'info', + "extra.sidekiq" => { + "another_field" => "field", + "args" => ["[FILTERED]", "1", "2"], + "class" => "HelloWorker" + } + ) + end +end diff --git a/spec/lib/gitlab/error_tracking/processor/context_payload_processor_spec.rb b/spec/lib/gitlab/error_tracking/processor/context_payload_processor_spec.rb new file mode 100644 index 00000000000..0db40eca989 --- /dev/null +++ b/spec/lib/gitlab/error_tracking/processor/context_payload_processor_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::ErrorTracking::Processor::ContextPayloadProcessor do + subject(:processor) { described_class.new } + + before do + allow_next_instance_of(Gitlab::ErrorTracking::ContextPayloadGenerator) do |generator| + allow(generator).to receive(:generate).and_return( + user: { username: 'root' }, + tags: { locale: 'en', program: 'test', feature_category: 'feature_a', correlation_id: 'cid' }, + extra: { some_info: 'info' } + ) + end + end + + it 'merges the context payload into event payload' do + payload = { + user: { ip_address: '127.0.0.1' }, + tags: { priority: 'high' }, + extra: { sidekiq: { class: 'SomeWorker', args: ['[FILTERED]', 1, 2] } } + } + + processor.process(payload) + + expect(payload).to eql( + user: { + ip_address: '127.0.0.1', + username: 'root' + }, + tags: { + priority: 'high', + locale: 'en', + program: 'test', + feature_category: 'feature_a', + correlation_id: 'cid' + }, + extra: { + some_info: 'info', + sidekiq: { class: 'SomeWorker', args: ['[FILTERED]', 1, 2] } + } + ) + end +end diff --git a/spec/lib/gitlab/error_tracking_spec.rb b/spec/lib/gitlab/error_tracking_spec.rb index 764478ad1d7..a905b9f8d40 100644 --- a/spec/lib/gitlab/error_tracking_spec.rb +++ b/spec/lib/gitlab/error_tracking_spec.rb @@ -8,116 +8,55 @@ RSpec.describe Gitlab::ErrorTracking do let(:exception) { RuntimeError.new('boom') } let(:issue_url) { 'http://gitlab.com/gitlab-org/gitlab-foss/issues/1' } - let(:expected_payload_includes) do - [ - { 'exception.class' => 'RuntimeError' }, - { 'exception.message' => 'boom' }, - { 'tags.correlation_id' => 'cid' }, - { 'extra.some_other_info' => 'info' }, - { 'extra.issue_url' => 'http://gitlab.com/gitlab-org/gitlab-foss/issues/1' } - ] + let(:user) { create(:user) } + + let(:sentry_payload) do + { + tags: { + program: 'test', + locale: 'en', + feature_category: 'feature_a', + correlation_id: 'cid' + }, + user: { + username: user.username + }, + extra: { + some_other_info: 'info', + issue_url: 'http://gitlab.com/gitlab-org/gitlab-foss/issues/1' + } + } end - let(:sentry_event) { Gitlab::Json.parse(Raven.client.transport.events.last[1]) } + let(:logger_payload) do + { + 'exception.class' => 'RuntimeError', + 'exception.message' => 'boom', + 'tags.program' => 'test', + 'tags.locale' => 'en', + 'tags.feature_category' => 'feature_a', + 'tags.correlation_id' => 'cid', + 'user.username' => user.username, + 'extra.some_other_info' => 'info', + 'extra.issue_url' => 'http://gitlab.com/gitlab-org/gitlab-foss/issues/1' + } + end before do stub_sentry_settings allow(described_class).to receive(:sentry_dsn).and_return(Gitlab.config.sentry.dsn) allow(Labkit::Correlation::CorrelationId).to receive(:current_id).and_return('cid') + allow(I18n).to receive(:locale).and_return('en') described_class.configure do |config| config.encoding = 'json' end end - describe '.configure' do - context 'default tags from GITLAB_SENTRY_EXTRA_TAGS' do - context 'when the value is a JSON hash' do - it 'includes those tags in all events' do - stub_env('GITLAB_SENTRY_EXTRA_TAGS', { foo: 'bar', baz: 'quux' }.to_json) - - described_class.configure do |config| - config.encoding = 'json' - end - - described_class.track_exception(StandardError.new) - - expect(sentry_event['tags'].except('correlation_id', 'locale', 'program')) - .to eq('foo' => 'bar', 'baz' => 'quux') - end - end - - context 'when the value is not set' do - before do - stub_env('GITLAB_SENTRY_EXTRA_TAGS', nil) - end - - it 'does not log an error' do - expect(Gitlab::AppLogger).not_to receive(:debug) - - described_class.configure do |config| - config.encoding = 'json' - end - end - - it 'does not send any extra tags' do - described_class.configure do |config| - config.encoding = 'json' - end - - described_class.track_exception(StandardError.new) - - expect(sentry_event['tags'].keys).to contain_exactly('correlation_id', 'locale', 'program') - end - end - - context 'when the value is not a JSON hash' do - using RSpec::Parameterized::TableSyntax - - where(:env_var, :error) do - { foo: 'bar', baz: 'quux' }.inspect | 'JSON::ParserError' - [].to_json | 'NoMethodError' - [%w[foo bar]].to_json | 'NoMethodError' - %w[foo bar].to_json | 'NoMethodError' - '"string"' | 'NoMethodError' - end - - with_them do - before do - stub_env('GITLAB_SENTRY_EXTRA_TAGS', env_var) - end - - it 'does not include any extra tags' do - described_class.configure do |config| - config.encoding = 'json' - end - - described_class.track_exception(StandardError.new) - - expect(sentry_event['tags'].except('correlation_id', 'locale', 'program')) - .to be_empty - end - - it 'logs the error class' do - expect(Gitlab::AppLogger).to receive(:debug).with(a_string_matching(error)) - - described_class.configure do |config| - config.encoding = 'json' - end - end - end - end - end - end - - describe '.with_context' do - it 'adds the expected tags' do - described_class.with_context {} - - expect(Raven.tags_context[:locale].to_s).to eq(I18n.locale.to_s) - expect(Raven.tags_context[Labkit::Correlation::CorrelationId::LOG_KEY.to_sym].to_s) - .to eq('cid') + around do |example| + Gitlab::ApplicationContext.with_context(user: user, feature_category: 'feature_a') do + example.run end end @@ -128,10 +67,15 @@ RSpec.describe Gitlab::ErrorTracking do end it 'raises the exception' do - expect(Raven).to receive(:capture_exception) - - expect { described_class.track_and_raise_for_dev_exception(exception) } - .to raise_error(RuntimeError) + expect(Raven).to receive(:capture_exception).with(exception, sentry_payload) + + expect do + described_class.track_and_raise_for_dev_exception( + exception, + issue_url: issue_url, + some_other_info: 'info' + ) + end.to raise_error(RuntimeError, /boom/) end end @@ -141,19 +85,7 @@ RSpec.describe Gitlab::ErrorTracking do end it 'logs the exception with all attributes passed' do - expected_extras = { - some_other_info: 'info', - issue_url: 'http://gitlab.com/gitlab-org/gitlab-foss/issues/1' - } - - expected_tags = { - correlation_id: 'cid' - } - - expect(Raven).to receive(:capture_exception) - .with(exception, - tags: a_hash_including(expected_tags), - extra: a_hash_including(expected_extras)) + expect(Raven).to receive(:capture_exception).with(exception, sentry_payload) described_class.track_and_raise_for_dev_exception( exception, @@ -163,8 +95,7 @@ RSpec.describe Gitlab::ErrorTracking do end it 'calls Gitlab::ErrorTracking::Logger.error with formatted payload' do - expect(Gitlab::ErrorTracking::Logger).to receive(:error) - .with(a_hash_including(*expected_payload_includes)) + expect(Gitlab::ErrorTracking::Logger).to receive(:error).with(logger_payload) described_class.track_and_raise_for_dev_exception( exception, @@ -177,15 +108,19 @@ RSpec.describe Gitlab::ErrorTracking do describe '.track_and_raise_exception' do it 'always raises the exception' do - expect(Raven).to receive(:capture_exception) + expect(Raven).to receive(:capture_exception).with(exception, sentry_payload) - expect { described_class.track_and_raise_exception(exception) } - .to raise_error(RuntimeError) + expect do + described_class.track_and_raise_for_dev_exception( + exception, + issue_url: issue_url, + some_other_info: 'info' + ) + end.to raise_error(RuntimeError, /boom/) end it 'calls Gitlab::ErrorTracking::Logger.error with formatted payload' do - expect(Gitlab::ErrorTracking::Logger).to receive(:error) - .with(a_hash_including(*expected_payload_includes)) + expect(Gitlab::ErrorTracking::Logger).to receive(:error).with(logger_payload) expect do described_class.track_and_raise_exception( @@ -210,17 +145,16 @@ RSpec.describe Gitlab::ErrorTracking do it 'calls Raven.capture_exception' do track_exception - expect(Raven).to have_received(:capture_exception) - .with(exception, - tags: a_hash_including(correlation_id: 'cid'), - extra: a_hash_including(some_other_info: 'info', issue_url: issue_url)) + expect(Raven).to have_received(:capture_exception).with( + exception, + sentry_payload + ) end it 'calls Gitlab::ErrorTracking::Logger.error with formatted payload' do track_exception - expect(Gitlab::ErrorTracking::Logger).to have_received(:error) - .with(a_hash_including(*expected_payload_includes)) + expect(Gitlab::ErrorTracking::Logger).to have_received(:error).with(logger_payload) end context 'with filterable parameters' do @@ -229,8 +163,9 @@ RSpec.describe Gitlab::ErrorTracking do it 'filters parameters' do track_exception - expect(Gitlab::ErrorTracking::Logger).to have_received(:error) - .with(hash_including({ 'extra.test' => 1, 'extra.my_token' => '[FILTERED]' })) + expect(Gitlab::ErrorTracking::Logger).to have_received(:error).with( + hash_including({ 'extra.test' => 1, 'extra.my_token' => '[FILTERED]' }) + ) end end @@ -241,8 +176,9 @@ RSpec.describe Gitlab::ErrorTracking do it 'includes the extra data from the exception in the tracking information' do track_exception - expect(Raven).to have_received(:capture_exception) - .with(exception, a_hash_including(extra: a_hash_including(extra_info))) + expect(Raven).to have_received(:capture_exception).with( + exception, a_hash_including(extra: a_hash_including(extra_info)) + ) end end @@ -253,8 +189,9 @@ RSpec.describe Gitlab::ErrorTracking do it 'just includes the other extra info' do track_exception - expect(Raven).to have_received(:capture_exception) - .with(exception, a_hash_including(extra: a_hash_including(extra))) + expect(Raven).to have_received(:capture_exception).with( + exception, a_hash_including(extra: a_hash_including(extra)) + ) end end @@ -266,7 +203,13 @@ RSpec.describe Gitlab::ErrorTracking do track_exception expect(Gitlab::ErrorTracking::Logger).to have_received(:error).with( - hash_including({ 'extra.sidekiq' => { 'class' => 'PostReceive', 'args' => ['1', '{"id"=>2, "name"=>"hello"}', 'some-value', 'another-value'] } })) + hash_including( + 'extra.sidekiq' => { + 'class' => 'PostReceive', + 'args' => ['1', '{"id"=>2, "name"=>"hello"}', 'some-value', 'another-value'] + } + ) + ) end end @@ -276,9 +219,17 @@ RSpec.describe Gitlab::ErrorTracking do it 'filters sensitive arguments before sending' do track_exception + sentry_event = Gitlab::Json.parse(Raven.client.transport.events.last[1]) + expect(sentry_event.dig('extra', 'sidekiq', 'args')).to eq(['[FILTERED]', 1, 2]) expect(Gitlab::ErrorTracking::Logger).to have_received(:error).with( - hash_including('extra.sidekiq' => { 'class' => 'UnknownWorker', 'args' => ['[FILTERED]', '1', '2'] })) + hash_including( + 'extra.sidekiq' => { + 'class' => 'UnknownWorker', + 'args' => ['[FILTERED]', '1', '2'] + } + ) + ) end end end diff --git a/spec/lib/gitlab/etag_caching/router/graphql_spec.rb b/spec/lib/gitlab/etag_caching/router/graphql_spec.rb new file mode 100644 index 00000000000..d151dcba413 --- /dev/null +++ b/spec/lib/gitlab/etag_caching/router/graphql_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::EtagCaching::Router::Graphql do + it 'matches pipelines endpoint' do + result = match_route('/api/graphql', 'pipelines/id/1') + + expect(result).to be_present + expect(result.name).to eq 'pipelines_graph' + end + + it 'has a valid feature category for every route', :aggregate_failures do + feature_categories = YAML.load_file(Rails.root.join('config', 'feature_categories.yml')).to_set + + described_class::ROUTES.each do |route| + expect(feature_categories).to include(route.feature_category), "#{route.name} has a category of #{route.feature_category}, which is not valid" + end + end + + def match_route(path, header) + described_class.match( + double(path_info: path, + headers: { 'X-GITLAB-GRAPHQL-RESOURCE-ETAG' => header })) + end + + describe '.cache_key' do + let(:path) { '/api/graphql' } + let(:header_value) { 'pipelines/id/1' } + let(:headers) do + { 'X-GITLAB-GRAPHQL-RESOURCE-ETAG' => header_value }.compact + end + + subject do + described_class.cache_key(double(path: path, headers: headers)) + end + + it 'uses request path and headers as cache key' do + is_expected.to eq '/api/graphql:pipelines/id/1' + end + + context 'when the header is missing' do + let(:header_value) {} + + it 'does not raise errors' do + is_expected.to eq '/api/graphql' + end + end + end +end diff --git a/spec/lib/gitlab/etag_caching/router/restful_spec.rb b/spec/lib/gitlab/etag_caching/router/restful_spec.rb new file mode 100644 index 00000000000..877789b320f --- /dev/null +++ b/spec/lib/gitlab/etag_caching/router/restful_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::EtagCaching::Router::Restful do + it 'matches issue notes endpoint' do + result = match_route('/my-group/and-subgroup/here-comes-the-project/noteable/issue/1/notes') + + expect(result).to be_present + expect(result.name).to eq 'issue_notes' + end + + it 'matches MR notes endpoint' do + result = match_route('/my-group/and-subgroup/here-comes-the-project/noteable/merge_request/1/notes') + + expect(result).to be_present + expect(result.name).to eq 'merge_request_notes' + end + + it 'matches issue title endpoint' do + result = match_route('/my-group/my-project/-/issues/123/realtime_changes') + + expect(result).to be_present + expect(result.name).to eq 'issue_title' + end + + it 'matches with a project name that includes a suffix of create' do + result = match_route('/group/test-create/-/issues/123/realtime_changes') + + expect(result).to be_present + expect(result.name).to eq 'issue_title' + end + + it 'matches with a project name that includes a prefix of create' do + result = match_route('/group/create-test/-/issues/123/realtime_changes') + + expect(result).to be_present + expect(result.name).to eq 'issue_title' + end + + it 'matches project pipelines endpoint' do + result = match_route('/my-group/my-project/-/pipelines.json') + + expect(result).to be_present + expect(result.name).to eq 'project_pipelines' + end + + it 'matches commit pipelines endpoint' do + result = match_route('/my-group/my-project/-/commit/aa8260d253a53f73f6c26c734c72fdd600f6e6d4/pipelines.json') + + expect(result).to be_present + expect(result.name).to eq 'commit_pipelines' + end + + it 'matches new merge request pipelines endpoint' do + result = match_route('/my-group/my-project/-/merge_requests/new.json') + + expect(result).to be_present + expect(result.name).to eq 'new_merge_request_pipelines' + end + + it 'matches merge request pipelines endpoint' do + result = match_route('/my-group/my-project/-/merge_requests/234/pipelines.json') + + expect(result).to be_present + expect(result.name).to eq 'merge_request_pipelines' + end + + it 'matches build endpoint' do + result = match_route('/my-group/my-project/builds/234.json') + + expect(result).to be_present + expect(result.name).to eq 'project_build' + end + + it 'does not match blob with confusing name' do + result = match_route('/my-group/my-project/-/blob/master/pipelines.json') + + expect(result).to be_blank + end + + it 'matches the cluster environments path' do + result = match_route('/my-group/my-project/-/clusters/47/environments') + + expect(result).to be_present + expect(result.name).to eq 'cluster_environments' + end + + it 'matches the environments path' do + result = match_route('/my-group/my-project/environments.json') + + expect(result).to be_present + expect(result.name).to eq 'environments' + end + + it 'matches pipeline#show endpoint' do + result = match_route('/my-group/my-project/-/pipelines/2.json') + + expect(result).to be_present + expect(result.name).to eq 'project_pipeline' + end + + it 'has a valid feature category for every route', :aggregate_failures do + feature_categories = YAML.load_file(Rails.root.join('config', 'feature_categories.yml')).to_set + + described_class::ROUTES.each do |route| + expect(feature_categories).to include(route.feature_category), "#{route.name} has a category of #{route.feature_category}, which is not valid" + end + end + + def match_route(path) + described_class.match(double(path_info: path)) + end + + describe '.cache_key' do + subject do + described_class.cache_key(double(path: '/my-group/my-project/builds/234.json')) + end + + it 'uses request path as cache key' do + is_expected.to eq '/my-group/my-project/builds/234.json' + end + end +end diff --git a/spec/lib/gitlab/etag_caching/router_spec.rb b/spec/lib/gitlab/etag_caching/router_spec.rb index dbd9cc230f1..c748ee00721 100644 --- a/spec/lib/gitlab/etag_caching/router_spec.rb +++ b/spec/lib/gitlab/etag_caching/router_spec.rb @@ -3,136 +3,33 @@ require 'spec_helper' RSpec.describe Gitlab::EtagCaching::Router do - it 'matches issue notes endpoint' do - result = described_class.match( - '/my-group/and-subgroup/here-comes-the-project/noteable/issue/1/notes' - ) - - expect(result).to be_present - expect(result.name).to eq 'issue_notes' - end - - it 'matches MR notes endpoint' do - result = described_class.match( - '/my-group/and-subgroup/here-comes-the-project/noteable/merge_request/1/notes' - ) - - expect(result).to be_present - expect(result.name).to eq 'merge_request_notes' - end - - it 'matches issue title endpoint' do - result = described_class.match( - '/my-group/my-project/-/issues/123/realtime_changes' - ) - - expect(result).to be_present - expect(result.name).to eq 'issue_title' - end - - it 'matches with a project name that includes a suffix of create' do - result = described_class.match( - '/group/test-create/-/issues/123/realtime_changes' - ) - - expect(result).to be_present - expect(result.name).to eq 'issue_title' - end - - it 'matches with a project name that includes a prefix of create' do - result = described_class.match( - '/group/create-test/-/issues/123/realtime_changes' - ) - - expect(result).to be_present - expect(result.name).to eq 'issue_title' - end - - it 'matches project pipelines endpoint' do - result = described_class.match( - '/my-group/my-project/-/pipelines.json' - ) - - expect(result).to be_present - expect(result.name).to eq 'project_pipelines' - end - - it 'matches commit pipelines endpoint' do - result = described_class.match( - '/my-group/my-project/-/commit/aa8260d253a53f73f6c26c734c72fdd600f6e6d4/pipelines.json' - ) - - expect(result).to be_present - expect(result.name).to eq 'commit_pipelines' - end - - it 'matches new merge request pipelines endpoint' do - result = described_class.match( - '/my-group/my-project/-/merge_requests/new.json' - ) - - expect(result).to be_present - expect(result.name).to eq 'new_merge_request_pipelines' - end - - it 'matches merge request pipelines endpoint' do - result = described_class.match( - '/my-group/my-project/-/merge_requests/234/pipelines.json' - ) - - expect(result).to be_present - expect(result.name).to eq 'merge_request_pipelines' - end - - it 'matches build endpoint' do - result = described_class.match( - '/my-group/my-project/builds/234.json' - ) - - expect(result).to be_present - expect(result.name).to eq 'project_build' - end - - it 'does not match blob with confusing name' do - result = described_class.match( - '/my-group/my-project/-/blob/master/pipelines.json' - ) - - expect(result).to be_blank - end + describe '.match', :aggregate_failures do + context 'with RESTful routes' do + it 'matches project pipelines endpoint' do + result = match_route('/my-group/my-project/-/pipelines.json') + + expect(result).to be_present + expect(result.name).to eq 'project_pipelines' + expect(result.router).to eq Gitlab::EtagCaching::Router::Restful + end + end - it 'matches the cluster environments path' do - result = described_class.match( - '/my-group/my-project/-/clusters/47/environments' - ) + context 'with GraphQL routes' do + it 'matches pipelines endpoint' do + result = match_route('/api/graphql', 'pipelines/id/12') - expect(result).to be_present - expect(result.name).to eq 'cluster_environments' + expect(result).to be_present + expect(result.name).to eq 'pipelines_graph' + expect(result.router).to eq Gitlab::EtagCaching::Router::Graphql + end + end end - it 'matches the environments path' do - result = described_class.match( - '/my-group/my-project/environments.json' - ) + def match_route(path, header = nil) + headers = { 'X-GITLAB-GRAPHQL-RESOURCE-ETAG' => header }.compact - expect(result).to be_present - expect(result.name).to eq 'environments' - end - - it 'matches pipeline#show endpoint' do - result = described_class.match( - '/my-group/my-project/-/pipelines/2.json' + described_class.match( + double(path_info: path, headers: headers) ) - - expect(result).to be_present - expect(result.name).to eq 'project_pipeline' - end - - it 'has a valid feature category for every route', :aggregate_failures do - feature_categories = YAML.load_file(Rails.root.join('config', 'feature_categories.yml')).to_set - - described_class::ROUTES.each do |route| - expect(feature_categories).to include(route.feature_category), "#{route.name} has a category of #{route.feature_category}, which is not valid" - end end end diff --git a/spec/lib/gitlab/etag_caching/store_spec.rb b/spec/lib/gitlab/etag_caching/store_spec.rb new file mode 100644 index 00000000000..46195e64715 --- /dev/null +++ b/spec/lib/gitlab/etag_caching/store_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::EtagCaching::Store, :clean_gitlab_redis_shared_state do + let(:store) { described_class.new } + + describe '#get' do + subject { store.get(key) } + + context 'with invalid keys' do + let(:key) { 'a' } + + it 'raises errors' do + expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).and_call_original + + expect { subject }.to raise_error Gitlab::EtagCaching::Store::InvalidKeyError + end + + it 'does not raise errors in production' do + expect(store).to receive(:skip_validation?).and_return true + expect(Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception) + + subject + end + end + + context 'with GraphQL keys' do + let(:key) { '/api/graphql:pipelines/id/5' } + + it 'returns a stored value' do + etag = store.touch(key) + + is_expected.to eq(etag) + end + end + + context 'with RESTful keys' do + let(:key) { '/my-group/my-project/builds/234.json' } + + it 'returns a stored value' do + etag = store.touch(key) + + is_expected.to eq(etag) + end + end + end + + describe '#touch' do + subject { store.touch(key) } + + context 'with invalid keys' do + let(:key) { 'a' } + + it 'raises errors' do + expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).and_call_original + + expect { subject }.to raise_error Gitlab::EtagCaching::Store::InvalidKeyError + end + end + + context 'with GraphQL keys' do + let(:key) { '/api/graphql:pipelines/id/5' } + + it 'stores and returns a value' do + etag = store.touch(key) + + expect(etag).to be_present + expect(store.get(key)).to eq(etag) + end + end + + context 'with RESTful keys' do + let(:key) { '/my-group/my-project/builds/234.json' } + + it 'stores and returns a value' do + etag = store.touch(key) + + expect(etag).to be_present + expect(store.get(key)).to eq(etag) + end + end + end +end diff --git a/spec/lib/gitlab/experimentation/controller_concern_spec.rb b/spec/lib/gitlab/experimentation/controller_concern_spec.rb index 1cebe37bea5..3678aeb18b0 100644 --- a/spec/lib/gitlab/experimentation/controller_concern_spec.rb +++ b/spec/lib/gitlab/experimentation/controller_concern_spec.rb @@ -520,6 +520,78 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do end end + describe '#record_experiment_group' do + let(:group) { 'a group object' } + let(:experiment_key) { :some_experiment_key } + let(:dnt_enabled) { false } + let(:experiment_active) { true } + let(:rollout_strategy) { :whatever } + let(:variant) { 'variant' } + + before do + allow(controller).to receive(:dnt_enabled?).and_return(dnt_enabled) + allow(::Gitlab::Experimentation).to receive(:active?).and_return(experiment_active) + allow(::Gitlab::Experimentation).to receive(:rollout_strategy).and_return(rollout_strategy) + allow(controller).to receive(:tracking_group).and_return(variant) + allow(::Experiment).to receive(:add_group) + end + + subject(:record_experiment_group) { controller.record_experiment_group(experiment_key, group) } + + shared_examples 'exits early without recording' do + it 'returns early without recording the group as an ExperimentSubject' do + expect(::Experiment).not_to receive(:add_group) + record_experiment_group + end + end + + shared_examples 'calls tracking_group' do |using_cookie_rollout| + it "calls tracking_group with #{using_cookie_rollout ? 'a nil' : 'the group as the'} subject" do + expect(controller).to receive(:tracking_group).with(experiment_key, nil, subject: using_cookie_rollout ? nil : group).and_return(variant) + record_experiment_group + end + end + + shared_examples 'records the group' do + it 'records the group' do + expect(::Experiment).to receive(:add_group).with(experiment_key, group: group, variant: variant) + record_experiment_group + end + end + + context 'when DNT is enabled' do + let(:dnt_enabled) { true } + + include_examples 'exits early without recording' + end + + context 'when the experiment is not active' do + let(:experiment_active) { false } + + include_examples 'exits early without recording' + end + + context 'when a nil group is given' do + let(:group) { nil } + + include_examples 'exits early without recording' + end + + context 'when the experiment uses a cookie-based rollout strategy' do + let(:rollout_strategy) { :cookie } + + include_examples 'calls tracking_group', true + include_examples 'records the group' + end + + context 'when the experiment uses a non-cookie-based rollout strategy' do + let(:rollout_strategy) { :group } + + include_examples 'calls tracking_group', false + include_examples 'records the group' + end + end + describe '#record_experiment_conversion_event' do let(:user) { build(:user) } @@ -534,7 +606,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do end it 'records the conversion event for the experiment & user' do - expect(::Experiment).to receive(:record_conversion_event).with(:test_experiment, user) + expect(::Experiment).to receive(:record_conversion_event).with(:test_experiment, user, {}) record_conversion_event end diff --git a/spec/lib/gitlab/experimentation_spec.rb b/spec/lib/gitlab/experimentation_spec.rb index 7eeae3f3f33..83c6b556fc6 100644 --- a/spec/lib/gitlab/experimentation_spec.rb +++ b/spec/lib/gitlab/experimentation_spec.rb @@ -7,14 +7,10 @@ require 'spec_helper' RSpec.describe Gitlab::Experimentation::EXPERIMENTS do it 'temporarily ensures we know what experiments exist for backwards compatibility' do expected_experiment_keys = [ - :ci_notification_dot, :upgrade_link_in_user_menu_a, - :invite_members_version_a, :invite_members_version_b, :invite_members_empty_group_version_a, - :contact_sales_btn_in_app, - :customize_homepage, - :group_only_trials + :contact_sales_btn_in_app ] backwards_compatible_experiment_keys = described_class.filter { |_, v| v[:use_backwards_compatible_subject_index] }.keys diff --git a/spec/lib/gitlab/git/push_spec.rb b/spec/lib/gitlab/git/push_spec.rb index 8ba43b2967c..68cef558f6f 100644 --- a/spec/lib/gitlab/git/push_spec.rb +++ b/spec/lib/gitlab/git/push_spec.rb @@ -87,7 +87,7 @@ RSpec.describe Gitlab::Git::Push do it { is_expected.to be_force_push } end - context 'when called muiltiple times' do + context 'when called mulitiple times' do it 'does not make make multiple calls to the force push check' do expect(Gitlab::Checks::ForcePush).to receive(:force_push?).once diff --git a/spec/lib/gitlab/github_import/importer/pull_request_merged_by_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_request_merged_by_importer_spec.rb index 2999dc5bb41..e42b6d89c30 100644 --- a/spec/lib/gitlab/github_import/importer/pull_request_merged_by_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/pull_request_merged_by_importer_spec.rb @@ -5,37 +5,46 @@ require 'spec_helper' RSpec.describe Gitlab::GithubImport::Importer::PullRequestMergedByImporter, :clean_gitlab_redis_cache do let_it_be(:merge_request) { create(:merged_merge_request) } let(:project) { merge_request.project } - let(:created_at) { Time.new(2017, 1, 1, 12, 00).utc } + let(:merged_at) { Time.new(2017, 1, 1, 12, 00).utc } let(:client_double) { double(user: double(id: 999, login: 'merger', email: 'merger@email.com')) } let(:pull_request) do instance_double( Gitlab::GithubImport::Representation::PullRequest, iid: merge_request.iid, - created_at: created_at, + merged_at: merged_at, merged_by: double(id: 999, login: 'merger') ) end subject { described_class.new(pull_request, project, client_double) } - it 'assigns the merged by user when mapped' do - merge_user = create(:user, email: 'merger@email.com') + context 'when the merger user can be mapped' do + it 'assigns the merged by user when mapped' do + merge_user = create(:user, email: 'merger@email.com') - subject.execute + subject.execute - expect(merge_request.metrics.reload.merged_by).to eq(merge_user) + metrics = merge_request.metrics.reload + expect(metrics.merged_by).to eq(merge_user) + expect(metrics.merged_at).to eq(merged_at) + end end - it 'adds a note referencing the merger user when the user cannot be mapped' do - expect { subject.execute } - .to change(Note, :count).by(1) - .and not_change(merge_request, :updated_at) - - last_note = merge_request.notes.last - - expect(last_note.note).to eq("*Merged by: merger*") - expect(last_note.created_at).to eq(created_at) - expect(last_note.author).to eq(project.creator) + context 'when the merger user cannot be mapped to a gitlab user' do + it 'adds a note referencing the merger user' do + expect { subject.execute } + .to change(Note, :count).by(1) + .and not_change(merge_request, :updated_at) + + metrics = merge_request.metrics.reload + expect(metrics.merged_by).to be_nil + expect(metrics.merged_at).to eq(merged_at) + + last_note = merge_request.notes.last + expect(last_note.note).to eq("*Merged by: merger at 2017-01-01 12:00:00 UTC*") + expect(last_note.created_at).to eq(merged_at) + expect(last_note.author).to eq(project.creator) + end end end diff --git a/spec/lib/gitlab/github_import/importer/pull_request_review_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_request_review_importer_spec.rb index b2f993ac47c..290f3f51202 100644 --- a/spec/lib/gitlab/github_import/importer/pull_request_review_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/pull_request_review_importer_spec.rb @@ -19,8 +19,10 @@ RSpec.describe Gitlab::GithubImport::Importer::PullRequestReviewImporter, :clean context 'when the review is "APPROVED"' do let(:review) { create_review(type: 'APPROVED', note: '') } - it 'creates a note for the review' do - expect { subject.execute }.to change(Note, :count) + it 'creates a note for the review and approves the Merge Request' do + expect { subject.execute } + .to change(Note, :count).by(1) + .and change(Approval, :count).by(1) last_note = merge_request.notes.last expect(last_note.note).to eq('approved this merge request') @@ -31,6 +33,14 @@ RSpec.describe Gitlab::GithubImport::Importer::PullRequestReviewImporter, :clean expect(merge_request.approved_by_users.reload).to include(author) expect(merge_request.approvals.last.created_at).to eq(submitted_at) end + + it 'does nothing if the user already approved the merge request' do + create(:approval, merge_request: merge_request, user: author) + + expect { subject.execute } + .to change(Note, :count).by(0) + .and change(Approval, :count).by(0) + end end context 'when the review is "COMMENTED"' do diff --git a/spec/lib/gitlab/graphql/calls_gitaly/field_extension_spec.rb b/spec/lib/gitlab/graphql/calls_gitaly/field_extension_spec.rb new file mode 100644 index 00000000000..1d8849f7e38 --- /dev/null +++ b/spec/lib/gitlab/graphql/calls_gitaly/field_extension_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Gitlab::Graphql::CallsGitaly::FieldExtension, :request_store do + include GraphqlHelpers + + let(:field_args) { {} } + let(:owner) { fresh_object_type } + let(:field) do + ::Types::BaseField.new(name: 'value', type: GraphQL::STRING_TYPE, null: true, owner: owner, **field_args) + end + + def resolve_value + resolve_field(field, { value: 'foo' }, object_type: owner) + end + + context 'when the field calls gitaly' do + before do + owner.define_method :value do + Gitlab::SafeRequestStore['gitaly_call_actual'] = 1 + 'fresh-from-the-gitaly-mines!' + end + end + + context 'when the field has a constant complexity' do + let(:field_args) { { complexity: 100 } } + + it 'allows the call' do + expect { resolve_value }.not_to raise_error + end + end + + context 'when the field declares that it calls gitaly' do + let(:field_args) { { calls_gitaly: true } } + + it 'allows the call' do + expect { resolve_value }.not_to raise_error + end + end + + context 'when the field does not have these arguments' do + let(:field_args) { {} } + + it 'notices, and raises, mentioning the field' do + expect { resolve_value }.to raise_error(include('Object.value')) + end + end + end + + context 'when it does not call gitaly' do + let(:field_args) { {} } + + it 'does not raise' do + value = resolve_value + + expect(value).to eq 'foo' + end + end + + context 'when some field calls gitaly while we were waiting' do + let(:extension) { described_class.new(field: field, options: {}) } + + it 'is acceptable if all are accounted for' do + object = :anything + arguments = :any_args + + ::Gitlab::SafeRequestStore['gitaly_call_actual'] = 3 + ::Gitlab::SafeRequestStore['graphql_gitaly_accounted_for'] = 0 + + expect do |b| + extension.resolve(object: object, arguments: arguments, &b) + end.to yield_with_args(object, arguments, [3, 0]) + + ::Gitlab::SafeRequestStore['gitaly_call_actual'] = 13 + ::Gitlab::SafeRequestStore['graphql_gitaly_accounted_for'] = 10 + + expect { extension.after_resolve(value: 'foo', memo: [3, 0]) }.not_to raise_error + end + + it 'is unacceptable if some of the calls are unaccounted for' do + ::Gitlab::SafeRequestStore['gitaly_call_actual'] = 10 + ::Gitlab::SafeRequestStore['graphql_gitaly_accounted_for'] = 9 + + expect { extension.after_resolve(value: 'foo', memo: [0, 0]) }.to raise_error(include('Object.value')) + end + end +end diff --git a/spec/lib/gitlab/graphql/calls_gitaly/instrumentation_spec.rb b/spec/lib/gitlab/graphql/calls_gitaly/instrumentation_spec.rb deleted file mode 100644 index f16767f7d14..00000000000 --- a/spec/lib/gitlab/graphql/calls_gitaly/instrumentation_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true -require 'spec_helper' - -RSpec.describe Gitlab::Graphql::CallsGitaly::Instrumentation do - subject { described_class.new } - - describe '#calls_gitaly_check' do - let(:gitaly_field) { Types::BaseField.new(name: 'test', type: GraphQL::STRING_TYPE, null: true, calls_gitaly: true) } - let(:no_gitaly_field) { Types::BaseField.new(name: 'test', type: GraphQL::STRING_TYPE, null: true, calls_gitaly: false) } - - context 'if there are no Gitaly calls' do - it 'does not raise an error if calls_gitaly is false' do - expect { subject.send(:calls_gitaly_check, no_gitaly_field, 0) }.not_to raise_error - end - end - - context 'if there is at least 1 Gitaly call' do - it 'raises an error if calls_gitaly: is false or not defined' do - expect { subject.send(:calls_gitaly_check, no_gitaly_field, 1) }.to raise_error(/specify a constant complexity or add `calls_gitaly: true`/) - end - end - end -end diff --git a/spec/lib/gitlab/graphql/docs/renderer_spec.rb b/spec/lib/gitlab/graphql/docs/renderer_spec.rb index 064e0c6828b..5afed8c3390 100644 --- a/spec/lib/gitlab/graphql/docs/renderer_spec.rb +++ b/spec/lib/gitlab/graphql/docs/renderer_spec.rb @@ -5,27 +5,50 @@ require 'spec_helper' RSpec.describe Gitlab::Graphql::Docs::Renderer do describe '#contents' do # Returns a Schema that uses the given `type` - def mock_schema(type) + def mock_schema(type, field_description) query_type = Class.new(Types::BaseObject) do - graphql_name 'QueryType' + graphql_name 'Query' - field :foo, type, null: true + field :foo, type, null: true do + description field_description + argument :id, GraphQL::ID_TYPE, required: false, description: 'ID of the object.' + end end - GraphQL::Schema.define(query: query_type) + GraphQL::Schema.define( + query: query_type, + resolve_type: ->(obj, ctx) { raise 'Not a real schema' } + ) end - let_it_be(:template) { Rails.root.join('lib/gitlab/graphql/docs/templates/', 'default.md.haml') } + let_it_be(:template) { Rails.root.join('lib/gitlab/graphql/docs/templates/default.md.haml') } + let(:field_description) { 'List of objects.' } subject(:contents) do described_class.new( - mock_schema(type).graphql_definition, + mock_schema(type, field_description).graphql_definition, output_dir: nil, template: template ).contents end - context 'A type with a field with a [Array] return type' do + describe 'headings' do + let(:type) { ::GraphQL::INT_TYPE } + + it 'contains the expected sections' do + expect(contents.lines.map(&:chomp)).to include( + '## `Query` type', + '## Object types', + '## Enumeration types', + '## Scalar types', + '## Abstract types', + '### Unions', + '### Interfaces' + ) + end + end + + context 'when a field has a list type' do let(:type) do Class.new(Types::BaseObject) do graphql_name 'ArrayTest' @@ -35,19 +58,51 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do end specify do + type_name = '[String!]!' + inner_type = 'string' expectation = <<~DOC - ### ArrayTest + ### `ArrayTest` | Field | Type | Description | | ----- | ---- | ----------- | - | `foo` | String! => Array | A description. | + | `foo` | [`#{type_name}`](##{inner_type}) | A description. | DOC is_expected.to include(expectation) end + + describe 'a top level query field' do + let(:expectation) do + <<~DOC + ### `foo` + + List of objects. + + Returns [`ArrayTest`](#arraytest). + + #### Arguments + + | Name | Type | Description | + | ---- | ---- | ----------- | + | `id` | [`ID`](#id) | ID of the object. | + DOC + end + + it 'generates the query with arguments' do + expect(subject).to include(expectation) + end + + context 'when description does not end with `.`' do + let(:field_description) { 'List of objects' } + + it 'adds the `.` to the end' do + expect(subject).to include(expectation) + end + end + end end - context 'A type with fields defined in reverse alphabetical order' do + describe 'when fields are not defined in alphabetical order' do let(:type) do Class.new(Types::BaseObject) do graphql_name 'OrderingTest' @@ -57,49 +112,56 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do end end - specify do + it 'lists the fields in alphabetical order' do expectation = <<~DOC - ### OrderingTest + ### `OrderingTest` | Field | Type | Description | | ----- | ---- | ----------- | - | `bar` | String! | A description of bar field. | - | `foo` | String! | A description of foo field. | + | `bar` | [`String!`](#string) | A description of bar field. | + | `foo` | [`String!`](#string) | A description of foo field. | DOC is_expected.to include(expectation) end end - context 'A type with a deprecated field' do + context 'when a field is deprecated' do let(:type) do Class.new(Types::BaseObject) do graphql_name 'DeprecatedTest' - field :foo, GraphQL::STRING_TYPE, null: false, deprecated: { reason: 'This is deprecated', milestone: '1.10' }, description: 'A description.' + field :foo, + type: GraphQL::STRING_TYPE, + null: false, + deprecated: { reason: 'This is deprecated', milestone: '1.10' }, + description: 'A description.' end end - specify do + it 'includes the deprecation' do expectation = <<~DOC - ### DeprecatedTest + ### `DeprecatedTest` | Field | Type | Description | | ----- | ---- | ----------- | - | `foo` **{warning-solid}** | String! | **Deprecated:** This is deprecated. Deprecated in 1.10. | + | `foo` **{warning-solid}** | [`String!`](#string) | **Deprecated:** This is deprecated. Deprecated in 1.10. | DOC is_expected.to include(expectation) end end - context 'A type with an emum field' do + context 'when a field has an Enumeration type' do let(:type) do enum_type = Class.new(Types::BaseEnum) do graphql_name 'MyEnum' - value 'BAZ', description: 'A description of BAZ.' - value 'BAR', description: 'A description of BAR.', deprecated: { reason: 'This is deprecated', milestone: '1.10' } + value 'BAZ', + description: 'A description of BAZ.' + value 'BAR', + description: 'A description of BAR.', + deprecated: { reason: 'This is deprecated', milestone: '1.10' } end Class.new(Types::BaseObject) do @@ -109,9 +171,9 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do end end - specify do + it 'includes the description of the Enumeration' do expectation = <<~DOC - ### MyEnum + ### `MyEnum` | Value | Description | | ----- | ----------- | @@ -122,5 +184,129 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do is_expected.to include(expectation) end end + + context 'when a field has a global ID type' do + let(:type) do + Class.new(Types::BaseObject) do + graphql_name 'IDTest' + description 'A test for rendering IDs.' + + field :foo, ::Types::GlobalIDType[::User], null: true, description: 'A user foo.' + end + end + + it 'includes the field and the description of the ID, so we can link to it' do + type_section = <<~DOC + ### `IDTest` + + A test for rendering IDs. + + | Field | Type | Description | + | ----- | ---- | ----------- | + | `foo` | [`UserID`](#userid) | A user foo. | + DOC + + id_section = <<~DOC + ### `UserID` + + A `UserID` is a global ID. It is encoded as a string. + + An example `UserID` is: `"gid://gitlab/User/1"`. + DOC + + is_expected.to include(type_section, id_section) + end + end + + context 'when there is an interface and a union' do + let(:type) do + user = Class.new(::Types::BaseObject) + user.graphql_name 'User' + user.field :user_field, ::GraphQL::STRING_TYPE, null: true + group = Class.new(::Types::BaseObject) + group.graphql_name 'Group' + group.field :group_field, ::GraphQL::STRING_TYPE, null: true + + union = Class.new(::Types::BaseUnion) + union.graphql_name 'UserOrGroup' + union.description 'Either a user or a group.' + union.possible_types user, group + + interface = Module.new + interface.include(::Types::BaseInterface) + interface.graphql_name 'Flying' + interface.description 'Something that can fly.' + interface.field :flight_speed, GraphQL::INT_TYPE, null: true, description: 'Speed in mph.' + + african_swallow = Class.new(::Types::BaseObject) + african_swallow.graphql_name 'AfricanSwallow' + african_swallow.description 'A swallow from Africa.' + african_swallow.implements interface + interface.orphan_types african_swallow + + Class.new(::Types::BaseObject) do + graphql_name 'AbstactTypeTest' + description 'A test for abstract types.' + + field :foo, union, null: true, description: 'The foo.' + field :flying, interface, null: true, description: 'A flying thing.' + end + end + + it 'lists the fields correctly, and includes descriptions of all the types' do + type_section = <<~DOC + ### `AbstactTypeTest` + + A test for abstract types. + + | Field | Type | Description | + | ----- | ---- | ----------- | + | `flying` | [`Flying`](#flying) | A flying thing. | + | `foo` | [`UserOrGroup`](#userorgroup) | The foo. | + DOC + + union_section = <<~DOC + #### `UserOrGroup` + + Either a user or a group. + + One of: + + - [`Group`](#group) + - [`User`](#user) + DOC + + interface_section = <<~DOC + #### `Flying` + + Something that can fly. + + Implementations: + + - [`AfricanSwallow`](#africanswallow) + + | Field | Type | Description | + | ----- | ---- | ----------- | + | `flightSpeed` | [`Int`](#int) | Speed in mph. | + DOC + + implementation_section = <<~DOC + ### `AfricanSwallow` + + A swallow from Africa. + + | Field | Type | Description | + | ----- | ---- | ----------- | + | `flightSpeed` | [`Int`](#int) | Speed in mph. | + DOC + + is_expected.to include( + type_section, + union_section, + interface_section, + implementation_section + ) + end + end end end diff --git a/spec/lib/gitlab/graphql/pagination/keyset/last_items_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/last_items_spec.rb index b45bb8b79d9..ec2ec4bf50d 100644 --- a/spec/lib/gitlab/graphql/pagination/keyset/last_items_spec.rb +++ b/spec/lib/gitlab/graphql/pagination/keyset/last_items_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Gitlab::Graphql::Pagination::Keyset::LastItems do let_it_be(:merge_request) { create(:merge_request) } - let(:scope) { MergeRequest.order_merged_at_asc.with_order_id_desc } + let(:scope) { MergeRequest.order_merged_at_asc } subject { described_class.take_items(*args) } diff --git a/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb index eb28e6c8c0a..40ee47ece49 100644 --- a/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb +++ b/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb @@ -52,18 +52,6 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::OrderInfo do end end - context 'when ordering by SIMILARITY' do - let(:relation) { Project.sorted_by_similarity_desc('test', include_in_select: true) } - - it 'assigns the right attribute name, named function, and direction' do - expect(order_list.count).to eq 2 - expect(order_list.first.attribute_name).to eq 'similarity' - expect(order_list.first.named_function).to be_kind_of(Arel::Nodes::Addition) - expect(order_list.first.named_function.to_sql).to include 'SIMILARITY(' - expect(order_list.first.sort_direction).to eq :desc - end - end - context 'when ordering by CASE', :aggregate_failuers do let(:relation) { Project.order(Arel::Nodes::Case.new(Project.arel_table[:pending_delete]).when(true).then(100).else(1000).asc) } diff --git a/spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb index fa631aa5666..31c02fd43e8 100644 --- a/spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb +++ b/spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb @@ -131,43 +131,5 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::QueryBuilder do end end end - - context 'when sorting using SIMILARITY' do - let(:relation) { Project.sorted_by_similarity_desc('test', include_in_select: true) } - let(:arel_table) { Project.arel_table } - let(:decoded_cursor) { { 'similarity' => 0.5, 'id' => 100 } } - let(:similarity_function_call) { Gitlab::Database::SimilarityScore::SIMILARITY_FUNCTION_CALL_WITH_ANNOTATION } - let(:similarity_sql) do - [ - "(#{similarity_function_call}(COALESCE(\"projects\".\"path\", ''), 'test') * CAST('1' AS numeric))", - "(#{similarity_function_call}(COALESCE(\"projects\".\"name\", ''), 'test') * CAST('0.7' AS numeric))", - "(#{similarity_function_call}(COALESCE(\"projects\".\"description\", ''), 'test') * CAST('0.2' AS numeric))" - ].join(' + ') - end - - context 'when no values are nil' do - context 'when :after' do - it 'generates the correct condition' do - conditions = builder.conditions.gsub(/\s+/, ' ') - - expect(conditions).to include "(#{similarity_sql} < 0.5)" - expect(conditions).to include '"projects"."id" < 100' - expect(conditions).to include "OR (#{similarity_sql} IS NULL)" - end - end - - context 'when :before' do - let(:before_or_after) { :before } - - it 'generates the correct condition' do - conditions = builder.conditions.gsub(/\s+/, ' ') - - expect(conditions).to include "(#{similarity_sql} > 0.5)" - expect(conditions).to include '"projects"."id" > 100' - expect(conditions).to include "OR ( #{similarity_sql} = 0.5" - end - end - end - end end end diff --git a/spec/lib/gitlab/graphql/present/field_extension_spec.rb b/spec/lib/gitlab/graphql/present/field_extension_spec.rb new file mode 100644 index 00000000000..5e66e16d655 --- /dev/null +++ b/spec/lib/gitlab/graphql/present/field_extension_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Gitlab::Graphql::Present::FieldExtension do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + + let(:object) { double(value: 'foo') } + let(:owner) { fresh_object_type } + let(:field_name) { 'value' } + let(:field) do + ::Types::BaseField.new(name: field_name, type: GraphQL::STRING_TYPE, null: true, owner: owner) + end + + let(:base_presenter) do + Class.new(SimpleDelegator) do + def initialize(object, **options) + super(object) + @object = object + @options = options + end + end + end + + def resolve_value + resolve_field(field, object, current_user: user, object_type: owner) + end + + context 'when the object does not declare a presenter' do + it 'does not affect normal resolution' do + expect(resolve_value).to eq 'foo' + end + end + + describe 'interactions with inheritance' do + def parent + type = fresh_object_type('Parent') + type.present_using(provide_foo) + type.field :foo, ::GraphQL::INT_TYPE, null: true + type.field :value, ::GraphQL::STRING_TYPE, null: true + type + end + + def child + type = Class.new(parent) + type.graphql_name 'Child' + type.present_using(provide_bar) + type.field :bar, ::GraphQL::INT_TYPE, null: true + type + end + + def provide_foo + Class.new(base_presenter) do + def foo + 100 + end + end + end + + def provide_bar + Class.new(base_presenter) do + def bar + 101 + end + end + end + + it 'can resolve value, foo and bar' do + type = child + value = resolve_field(:value, object, object_type: type) + foo = resolve_field(:foo, object, object_type: type) + bar = resolve_field(:bar, object, object_type: type) + + expect([value, foo, bar]).to eq ['foo', 100, 101] + end + end + + shared_examples 'calling the presenter method' do + it 'calls the presenter method' do + expect(resolve_value).to eq presenter.new(object, current_user: user).send(field_name) + end + end + + context 'when the object declares a presenter' do + before do + owner.present_using(presenter) + end + + context 'when the presenter overrides the original method' do + def twice + Class.new(base_presenter) do + def value + @object.value * 2 + end + end + end + + let(:presenter) { twice } + + it_behaves_like 'calling the presenter method' + end + + # This is exercised here using an explicit `resolve:` proc, but + # @resolver_proc values are used in field instrumentation as well. + context 'when the field uses a resolve proc' do + let(:presenter) { base_presenter } + let(:field) do + ::Types::BaseField.new( + name: field_name, + type: GraphQL::STRING_TYPE, + null: true, + owner: owner, + resolve: ->(obj, args, ctx) { 'Hello from a proc' } + ) + end + + specify { expect(resolve_value).to eq 'Hello from a proc' } + end + + context 'when the presenter provides a new method' do + def presenter + Class.new(base_presenter) do + def current_username + "Hello #{@options[:current_user]&.username} from the presenter!" + end + end + end + + context 'when we select the original field' do + it 'is unaffected' do + expect(resolve_value).to eq 'foo' + end + end + + context 'when we select the new field' do + let(:field_name) { 'current_username' } + + it_behaves_like 'calling the presenter method' + end + end + end +end diff --git a/spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb b/spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb index 138765afd8a..8450396284a 100644 --- a/spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb +++ b/spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb @@ -5,42 +5,6 @@ require 'spec_helper' RSpec.describe Gitlab::Graphql::QueryAnalyzers::LoggerAnalyzer do subject { described_class.new } - describe '#analyze?' do - context 'feature flag disabled' do - before do - stub_feature_flags(graphql_logging: false) - end - - it 'disables the analyzer' do - expect(subject.analyze?(anything)).to be_falsey - end - end - - context 'feature flag enabled by default' do - let(:monotonic_time_before) { 42 } - let(:monotonic_time_after) { 500 } - let(:monotonic_time_duration) { monotonic_time_after - monotonic_time_before } - - it 'enables the analyzer' do - expect(subject.analyze?(anything)).to be_truthy - end - - it 'returns a duration in seconds' do - allow(GraphQL::Analysis).to receive(:analyze_query).and_return([4, 2, [[], []]]) - allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(monotonic_time_before, monotonic_time_after) - allow(Gitlab::GraphqlLogger).to receive(:info) - - expected_duration = monotonic_time_duration - memo = subject.initial_value(spy('query')) - - subject.final_value(memo) - - expect(memo).to have_key(:duration_s) - expect(memo[:duration_s]).to eq(expected_duration) - end - end - end - describe '#initial_value' do it 'filters out sensitive variables' do doc = GraphQL.parse <<-GRAPHQL @@ -58,4 +22,24 @@ RSpec.describe Gitlab::Graphql::QueryAnalyzers::LoggerAnalyzer do expect(subject.initial_value(query)[:variables]).to eq('{:body=>"[FILTERED]"}') end end + + describe '#final_value' do + let(:monotonic_time_before) { 42 } + let(:monotonic_time_after) { 500 } + let(:monotonic_time_duration) { monotonic_time_after - monotonic_time_before } + + it 'returns a duration in seconds' do + allow(GraphQL::Analysis).to receive(:analyze_query).and_return([4, 2, [[], []]]) + allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(monotonic_time_before, monotonic_time_after) + allow(Gitlab::GraphqlLogger).to receive(:info) + + expected_duration = monotonic_time_duration + memo = subject.initial_value(spy('query')) + + subject.final_value(memo) + + expect(memo).to have_key(:duration_s) + expect(memo[:duration_s]).to eq(expected_duration) + end + end end diff --git a/spec/lib/gitlab/hook_data/project_member_builder_spec.rb b/spec/lib/gitlab/hook_data/project_member_builder_spec.rb new file mode 100644 index 00000000000..3fb84223581 --- /dev/null +++ b/spec/lib/gitlab/hook_data/project_member_builder_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::HookData::ProjectMemberBuilder do + let_it_be(:project) { create(:project, :internal, name: 'gitlab') } + let_it_be(:user) { create(:user, name: 'John Doe', username: 'johndoe', email: 'john@example.com') } + let_it_be(:project_member) { create(:project_member, :developer, user: user, project: project) } + + describe '#build' do + let(:data) { described_class.new(project_member).build(event) } + let(:event_name) { data[:event_name] } + let(:attributes) do + [ + :event_name, :created_at, :updated_at, :project_name, :project_path, :project_path_with_namespace, :project_id, :user_username, :user_name, :user_email, :user_id, :access_level, :project_visibility + ] + end + + context 'data' do + shared_examples_for 'includes the required attributes' do + it 'includes the required attributes' do + expect(data).to include(*attributes) + expect(data[:project_name]).to eq('gitlab') + expect(data[:project_path]).to eq(project.path) + expect(data[:project_path_with_namespace]).to eq(project.full_path) + expect(data[:project_id]).to eq(project.id) + expect(data[:user_username]).to eq('johndoe') + expect(data[:user_name]).to eq('John Doe') + expect(data[:user_id]).to eq(user.id) + expect(data[:user_email]).to eq('john@example.com') + expect(data[:access_level]).to eq('Developer') + expect(data[:project_visibility]).to eq('internal') + end + end + + context 'on create' do + let(:event) { :create } + + it { expect(event_name).to eq('user_add_to_team') } + it_behaves_like 'includes the required attributes' + end + + context 'on update' do + let(:event) { :update } + + it { expect(event_name).to eq('user_update_for_team') } + it_behaves_like 'includes the required attributes' + end + + context 'on destroy' do + let(:event) { :destroy } + + it { expect(event_name).to eq('user_remove_from_team') } + it_behaves_like 'includes the required attributes' + end + end + end +end diff --git a/spec/lib/gitlab/http_connection_adapter_spec.rb b/spec/lib/gitlab/http_connection_adapter_spec.rb index 389bc1a85f4..96e6e485841 100644 --- a/spec/lib/gitlab/http_connection_adapter_spec.rb +++ b/spec/lib/gitlab/http_connection_adapter_spec.rb @@ -5,17 +5,32 @@ require 'spec_helper' RSpec.describe Gitlab::HTTPConnectionAdapter do include StubRequests + let(:uri) { URI('https://example.org') } + let(:options) { {} } + + subject(:connection) { described_class.new(uri, options).connection } + describe '#connection' do before do stub_all_dns('https://example.org', ip_address: '93.184.216.34') end - context 'when local requests are not allowed' do + context 'when local requests are allowed' do + let(:options) { { allow_local_requests: true } } + it 'sets up the connection' do - uri = URI('https://example.org') + expect(connection).to be_a(Net::HTTP) + expect(connection.address).to eq('93.184.216.34') + expect(connection.hostname_override).to eq('example.org') + expect(connection.addr_port).to eq('example.org') + expect(connection.port).to eq(443) + end + end - connection = described_class.new(uri).connection + context 'when local requests are not allowed' do + let(:options) { { allow_local_requests: false } } + it 'sets up the connection' do expect(connection).to be_a(Net::HTTP) expect(connection.address).to eq('93.184.216.34') expect(connection.hostname_override).to eq('example.org') @@ -23,28 +38,57 @@ RSpec.describe Gitlab::HTTPConnectionAdapter do expect(connection.port).to eq(443) end - it 'raises error when it is a request to local address' do - uri = URI('http://172.16.0.0/12') + context 'when it is a request to local network' do + let(:uri) { URI('http://172.16.0.0/12') } + + it 'raises error' do + expect { subject }.to raise_error( + Gitlab::HTTP::BlockedUrlError, + "URL 'http://172.16.0.0/12' is blocked: Requests to the local network are not allowed" + ) + end + + context 'when local request allowed' do + let(:options) { { allow_local_requests: true } } - expect { described_class.new(uri).connection } - .to raise_error(Gitlab::HTTP::BlockedUrlError, - "URL 'http://172.16.0.0/12' is blocked: Requests to the local network are not allowed") + it 'sets up the connection' do + expect(connection).to be_a(Net::HTTP) + expect(connection.address).to eq('172.16.0.0') + expect(connection.hostname_override).to be(nil) + expect(connection.addr_port).to eq('172.16.0.0') + expect(connection.port).to eq(80) + end + end end - it 'raises error when it is a request to localhost address' do - uri = URI('http://127.0.0.1') + context 'when it is a request to local address' do + let(:uri) { URI('http://127.0.0.1') } + + it 'raises error' do + expect { subject }.to raise_error( + Gitlab::HTTP::BlockedUrlError, + "URL 'http://127.0.0.1' is blocked: Requests to localhost are not allowed" + ) + end - expect { described_class.new(uri).connection } - .to raise_error(Gitlab::HTTP::BlockedUrlError, - "URL 'http://127.0.0.1' is blocked: Requests to localhost are not allowed") + context 'when local request allowed' do + let(:options) { { allow_local_requests: true } } + + it 'sets up the connection' do + expect(connection).to be_a(Net::HTTP) + expect(connection.address).to eq('127.0.0.1') + expect(connection.hostname_override).to be(nil) + expect(connection.addr_port).to eq('127.0.0.1') + expect(connection.port).to eq(80) + end + end end context 'when port different from URL scheme is used' do - it 'sets up the addr_port accordingly' do - uri = URI('https://example.org:8080') - - connection = described_class.new(uri).connection + let(:uri) { URI('https://example.org:8080') } + it 'sets up the addr_port accordingly' do + expect(connection).to be_a(Net::HTTP) expect(connection.address).to eq('93.184.216.34') expect(connection.hostname_override).to eq('example.org') expect(connection.addr_port).to eq('example.org:8080') @@ -54,13 +98,11 @@ RSpec.describe Gitlab::HTTPConnectionAdapter do end context 'when DNS rebinding protection is disabled' do - it 'sets up the connection' do + before do stub_application_setting(dns_rebinding_protection_enabled: false) + end - uri = URI('https://example.org') - - connection = described_class.new(uri).connection - + it 'sets up the connection' do expect(connection).to be_a(Net::HTTP) expect(connection.address).to eq('example.org') expect(connection.hostname_override).to eq(nil) @@ -70,13 +112,11 @@ RSpec.describe Gitlab::HTTPConnectionAdapter do end context 'when http(s) environment variable is set' do - it 'sets up the connection' do + before do stub_env('https_proxy' => 'https://my.proxy') + end - uri = URI('https://example.org') - - connection = described_class.new(uri).connection - + it 'sets up the connection' do expect(connection).to be_a(Net::HTTP) expect(connection.address).to eq('example.org') expect(connection.hostname_override).to eq(nil) @@ -85,41 +125,128 @@ RSpec.describe Gitlab::HTTPConnectionAdapter do end end - context 'when local requests are allowed' do - it 'sets up the connection' do - uri = URI('https://example.org') + context 'when proxy settings are configured' do + let(:options) do + { + http_proxyaddr: 'https://proxy.org', + http_proxyport: 1557, + http_proxyuser: 'user', + http_proxypass: 'pass' + } + end - connection = described_class.new(uri, allow_local_requests: true).connection + before do + stub_all_dns('https://proxy.org', ip_address: '166.84.12.54') + end - expect(connection).to be_a(Net::HTTP) - expect(connection.address).to eq('93.184.216.34') - expect(connection.hostname_override).to eq('example.org') - expect(connection.addr_port).to eq('example.org') - expect(connection.port).to eq(443) + it 'sets up the proxy settings' do + expect(connection.proxy_address).to eq('https://166.84.12.54') + expect(connection.proxy_port).to eq(1557) + expect(connection.proxy_user).to eq('user') + expect(connection.proxy_pass).to eq('pass') end - it 'sets up the connection when it is a local network' do - uri = URI('http://172.16.0.0/12') + context 'when the address has path' do + before do + options[:http_proxyaddr] = 'https://proxy.org/path' + end - connection = described_class.new(uri, allow_local_requests: true).connection + it 'sets up the proxy settings' do + expect(connection.proxy_address).to eq('https://166.84.12.54/path') + expect(connection.proxy_port).to eq(1557) + end + end - expect(connection).to be_a(Net::HTTP) - expect(connection.address).to eq('172.16.0.0') - expect(connection.hostname_override).to be(nil) - expect(connection.addr_port).to eq('172.16.0.0') - expect(connection.port).to eq(80) + context 'when the port is in the address and port' do + before do + options[:http_proxyaddr] = 'https://proxy.org:1422' + end + + it 'sets up the proxy settings' do + expect(connection.proxy_address).to eq('https://166.84.12.54') + expect(connection.proxy_port).to eq(1557) + end + + context 'when the port is only in the address' do + before do + options[:http_proxyport] = nil + end + + it 'sets up the proxy settings' do + expect(connection.proxy_address).to eq('https://166.84.12.54') + expect(connection.proxy_port).to eq(1422) + end + end end - it 'sets up the connection when it is localhost' do - uri = URI('http://127.0.0.1') + context 'when it is a request to local network' do + before do + options[:http_proxyaddr] = 'http://172.16.0.0/12' + end + + it 'raises error' do + expect { subject }.to raise_error( + Gitlab::HTTP::BlockedUrlError, + "URL 'http://172.16.0.0:1557/12' is blocked: Requests to the local network are not allowed" + ) + end - connection = described_class.new(uri, allow_local_requests: true).connection + context 'when local request allowed' do + before do + options[:allow_local_requests] = true + end - expect(connection).to be_a(Net::HTTP) - expect(connection.address).to eq('127.0.0.1') - expect(connection.hostname_override).to be(nil) - expect(connection.addr_port).to eq('127.0.0.1') - expect(connection.port).to eq(80) + it 'sets up the connection' do + expect(connection.proxy_address).to eq('http://172.16.0.0/12') + expect(connection.proxy_port).to eq(1557) + end + end + end + + context 'when it is a request to local address' do + before do + options[:http_proxyaddr] = 'http://127.0.0.1' + end + + it 'raises error' do + expect { subject }.to raise_error( + Gitlab::HTTP::BlockedUrlError, + "URL 'http://127.0.0.1:1557' is blocked: Requests to localhost are not allowed" + ) + end + + context 'when local request allowed' do + before do + options[:allow_local_requests] = true + end + + it 'sets up the connection' do + expect(connection.proxy_address).to eq('http://127.0.0.1') + expect(connection.proxy_port).to eq(1557) + end + end + end + + context 'when http(s) environment variable is set' do + before do + stub_env('https_proxy' => 'https://my.proxy') + end + + it 'sets up the connection' do + expect(connection.proxy_address).to eq('https://proxy.org') + expect(connection.proxy_port).to eq(1557) + end + end + + context 'when DNS rebinding protection is disabled' do + before do + stub_application_setting(dns_rebinding_protection_enabled: false) + end + + it 'sets up the connection' do + expect(connection.proxy_address).to eq('https://proxy.org') + expect(connection.proxy_port).to eq(1557) + end end end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index d0282e14d5f..37b43066a62 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -335,6 +335,7 @@ container_repositories: - project - name project: +- external_approval_rules - taggings - base_tags - tag_taggings diff --git a/spec/lib/gitlab/import_export/import_export_spec.rb b/spec/lib/gitlab/import_export/import_export_spec.rb index 62b4717fc96..87757b07572 100644 --- a/spec/lib/gitlab/import_export/import_export_spec.rb +++ b/spec/lib/gitlab/import_export/import_export_spec.rb @@ -4,8 +4,8 @@ require 'spec_helper' RSpec.describe Gitlab::ImportExport do describe 'export filename' do - let(:group) { create(:group, :nested) } - let(:project) { create(:project, :public, path: 'project-path', namespace: group) } + let(:group) { build(:group, path: 'child', parent: build(:group, path: 'parent')) } + let(:project) { build(:project, :public, path: 'project-path', namespace: group) } it 'contains the project path' do expect(described_class.export_filename(exportable: project)).to include(project.path) diff --git a/spec/lib/gitlab/import_export/project/tree_saver_spec.rb b/spec/lib/gitlab/import_export/project/tree_saver_spec.rb index ece261e0882..50494433c5d 100644 --- a/spec/lib/gitlab/import_export/project/tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project/tree_saver_spec.rb @@ -349,14 +349,22 @@ RSpec.describe Gitlab::ImportExport::Project::TreeSaver do project_tree_saver.save end - it 'exports group members as admin' do - expect(member_emails).to include('group@member.com') - end + context 'when admin mode is enabled', :enable_admin_mode do + it 'exports group members as admin' do + expect(member_emails).to include('group@member.com') + end - it 'exports group members as project members' do - member_types = subject.map { |pm| pm['source_type'] } + it 'exports group members as project members' do + member_types = subject.map { |pm| pm['source_type'] } + + expect(member_types).to all(eq('Project')) + end + end - expect(member_types).to all(eq('Project')) + context 'when admin mode is disabled' do + it 'does not export group members' do + expect(member_emails).not_to include('group@member.com') + end end end end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index e301be47d68..b159d0cfc76 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -84,6 +84,7 @@ Note: - discussion_id - original_discussion_id - confidential +- last_edited_at LabelLink: - id - target_type @@ -500,6 +501,7 @@ ProtectedBranch: - name - created_at - updated_at +- allow_force_push - code_owner_approval_required ProtectedTag: - id @@ -584,6 +586,7 @@ ProjectFeature: - analytics_access_level - operations_access_level - security_and_compliance_access_level +- container_registry_access_level - created_at - updated_at ProtectedBranch::MergeAccessLevel: diff --git a/spec/lib/gitlab/marker_range_spec.rb b/spec/lib/gitlab/marker_range_spec.rb new file mode 100644 index 00000000000..5f73d2a5048 --- /dev/null +++ b/spec/lib/gitlab/marker_range_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::MarkerRange do + subject(:marker_range) { described_class.new(first, last, mode: mode) } + + let(:first) { 1 } + let(:last) { 10 } + let(:mode) { nil } + + it { is_expected.to eq(first..last) } + + it 'behaves like a Range' do + is_expected.to be_kind_of(Range) + end + + describe '#mode' do + subject { marker_range.mode } + + it { is_expected.to be_nil } + + context 'when mode is provided' do + let(:mode) { :deletion } + + it { is_expected.to eq(mode) } + end + end + + describe '#to_range' do + subject { marker_range.to_range } + + it { is_expected.to eq(first..last) } + + context 'when mode is provided' do + let(:mode) { :deletion } + + it 'is omitted during transformation' do + is_expected.not_to respond_to(:mode) + end + end + end + + describe '.from_range' do + subject { described_class.from_range(range) } + + let(:range) { 1..3 } + + it 'converts Range to MarkerRange object' do + is_expected.to be_a(described_class) + end + + it 'keeps correct range' do + is_expected.to eq(range) + end + + context 'when range excludes end' do + let(:range) { 1...3 } + + it 'keeps correct range' do + is_expected.to eq(range) + end + end + + context 'when range is already a MarkerRange' do + let(:range) { marker_range } + + it { is_expected.to be(marker_range) } + end + end +end diff --git a/spec/lib/gitlab/metrics/background_transaction_spec.rb b/spec/lib/gitlab/metrics/background_transaction_spec.rb new file mode 100644 index 00000000000..b31a2f7549a --- /dev/null +++ b/spec/lib/gitlab/metrics/background_transaction_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Metrics::BackgroundTransaction do + let(:transaction) { described_class.new } + let(:prometheus_metric) { instance_double(Prometheus::Client::Metric, base_labels: {}) } + + before do + allow(described_class).to receive(:prometheus_metric).and_return(prometheus_metric) + end + + describe '#run' do + it 'yields the supplied block' do + expect { |b| transaction.run(&b) }.to yield_control + end + + it 'stores the transaction in the current thread' do + transaction.run do + expect(Thread.current[described_class::BACKGROUND_THREAD_KEY]).to eq(transaction) + end + end + + it 'removes the transaction from the current thread upon completion' do + transaction.run { } + + expect(Thread.current[described_class::BACKGROUND_THREAD_KEY]).to be_nil + end + end + + describe '#labels' do + it 'provides labels with endpoint_id and feature_category' do + Labkit::Context.with_context(feature_category: 'projects', caller_id: 'TestWorker') do + expect(transaction.labels).to eq({ endpoint_id: 'TestWorker', feature_category: 'projects' }) + end + end + end + + RSpec.shared_examples 'metric with labels' do |metric_method| + it 'measures with correct labels and value' do + value = 1 + expect(prometheus_metric).to receive(metric_method).with({ endpoint_id: 'TestWorker', feature_category: 'projects' }, value) + + Labkit::Context.with_context(feature_category: 'projects', caller_id: 'TestWorker') do + transaction.send(metric_method, :test_metric, value) + end + end + end + + describe '#increment' do + let(:prometheus_metric) { instance_double(Prometheus::Client::Counter, :increment, base_labels: {}) } + + it_behaves_like 'metric with labels', :increment + end + + describe '#set' do + let(:prometheus_metric) { instance_double(Prometheus::Client::Gauge, :set, base_labels: {}) } + + it_behaves_like 'metric with labels', :set + end + + describe '#observe' do + let(:prometheus_metric) { instance_double(Prometheus::Client::Histogram, :observe, base_labels: {}) } + + it_behaves_like 'metric with labels', :observe + end +end diff --git a/spec/lib/gitlab/metrics/subscribers/action_cable_spec.rb b/spec/lib/gitlab/metrics/subscribers/action_cable_spec.rb new file mode 100644 index 00000000000..153cf43be0a --- /dev/null +++ b/spec/lib/gitlab/metrics/subscribers/action_cable_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Metrics::Subscribers::ActionCable, :request_store do + let(:subscriber) { described_class.new } + let(:counter) { double(:counter) } + let(:data) { { data: { event: 'updated' } } } + let(:channel_class) { 'IssuesChannel' } + let(:event) do + double( + :event, + name: name, + payload: payload + ) + end + + describe '#transmit' do + let(:name) { 'transmit.action_cable' } + let(:via) { 'streamed from issues:Z2lkOi8vZs2l0bGFiL0lzc3VlLzQ0Ng' } + let(:payload) do + { + channel_class: channel_class, + via: via, + data: data + } + end + + it 'tracks the transmit event' do + allow(::Gitlab::Metrics).to receive(:counter).with( + :action_cable_single_client_transmissions_total, /transmit/ + ).and_return(counter) + + expect(counter).to receive(:increment) + + subscriber.transmit(event) + end + end + + describe '#broadcast' do + let(:name) { 'broadcast.action_cable' } + let(:coder) { ActiveSupport::JSON } + let(:message) do + { event: :updated } + end + + let(:broadcasting) { 'issues:Z2lkOi8vZ2l0bGFiL0lzc3VlLzQ0Ng' } + let(:payload) do + { + broadcasting: broadcasting, + message: message, + coder: coder + } + end + + it 'tracks the broadcast event' do + allow(::Gitlab::Metrics).to receive(:counter).with( + :action_cable_broadcasts_total, /broadcast/ + ).and_return(counter) + + expect(counter).to receive(:increment) + + subscriber.broadcast(event) + end + end + + describe '#transmit_subscription_confirmation' do + let(:name) { 'transmit_subscription_confirmation.action_cable' } + let(:channel_class) { 'IssuesChannel' } + let(:payload) do + { + channel_class: channel_class + } + end + + it 'tracks the subscription confirmation event' do + allow(::Gitlab::Metrics).to receive(:counter).with( + :action_cable_subscription_confirmations_total, /confirm/ + ).and_return(counter) + + expect(counter).to receive(:increment) + + subscriber.transmit_subscription_confirmation(event) + end + end + + describe '#transmit_subscription_rejection' do + let(:name) { 'transmit_subscription_rejection.action_cable' } + let(:channel_class) { 'IssuesChannel' } + let(:payload) do + { + channel_class: channel_class + } + end + + it 'tracks the subscription rejection event' do + allow(::Gitlab::Metrics).to receive(:counter).with( + :action_cable_subscription_rejections_total, /reject/ + ).and_return(counter) + + expect(counter).to receive(:increment) + + subscriber.transmit_subscription_rejection(event) + end + end +end diff --git a/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb b/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb index edcd5b31941..dffd37eeb9d 100644 --- a/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb +++ b/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb @@ -3,10 +3,12 @@ require 'spec_helper' RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do + using RSpec::Parameterized::TableSyntax + let(:env) { {} } - let(:transaction) { Gitlab::Metrics::WebTransaction.new(env) } - let(:subscriber) { described_class.new } - let(:payload) { { sql: 'SELECT * FROM users WHERE id = 10' } } + let(:subscriber) { described_class.new } + let(:connection) { double(:connection) } + let(:payload) { { sql: 'SELECT * FROM users WHERE id = 10', connection: connection } } let(:event) do double( @@ -17,82 +19,32 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do ) end - describe '#sql' do - shared_examples 'track query in metrics' do - before do - allow(subscriber).to receive(:current_transaction) - .at_least(:once) - .and_return(transaction) - end - - it 'increments only db count value' do - described_class::DB_COUNTERS.each do |counter| - prometheus_counter = "gitlab_transaction_#{counter}_total".to_sym - if expected_counters[counter] > 0 - expect(transaction).to receive(:increment).with(prometheus_counter, 1) - else - expect(transaction).not_to receive(:increment).with(prometheus_counter, 1) - end - end - - subscriber.sql(event) - end + # Emulate Marginalia pre-pending comments + def sql(query, comments: true) + if comments && !%w[BEGIN COMMIT].include?(query) + "/*application:web,controller:badges,action:pipeline,correlation_id:01EYN39K9VMJC56Z7808N7RSRH*/ #{query}" + else + query end + end - shared_examples 'track query in RequestStore' do - context 'when RequestStore is enabled' do - it 'caches db count value', :request_store, :aggregate_failures do - subscriber.sql(event) - - described_class::DB_COUNTERS.each do |counter| - expect(Gitlab::SafeRequestStore[counter].to_i).to eq expected_counters[counter] - end - end - - it 'prevents db counters from leaking to the next transaction' do - 2.times do - Gitlab::WithRequestStore.with_request_store do - subscriber.sql(event) - - described_class::DB_COUNTERS.each do |counter| - expect(Gitlab::SafeRequestStore[counter].to_i).to eq expected_counters[counter] - end - end - end - end - end - end - - describe 'without a current transaction' do - it 'does not track any metrics' do - expect_any_instance_of(Gitlab::Metrics::Transaction) - .not_to receive(:increment) - - subscriber.sql(event) - end - - context 'with read query' do - let(:expected_counters) do - { - db_count: 1, - db_write_count: 0, - db_cached_count: 0 - } - end - - it_behaves_like 'track query in RequestStore' - end + shared_examples 'track generic sql events' do + where(:name, :sql_query, :record_query, :record_write_query, :record_cached_query) do + 'SQL' | 'SELECT * FROM users WHERE id = 10' | true | false | false + 'SQL' | 'WITH active_milestones AS (SELECT COUNT(*), state FROM milestones GROUP BY state) SELECT * FROM active_milestones' | true | false | false + 'SQL' | 'SELECT * FROM users WHERE id = 10 FOR UPDATE' | true | true | false + 'SQL' | 'WITH archived_rows AS (SELECT * FROM users WHERE archived = true) INSERT INTO products_log SELECT * FROM archived_rows' | true | true | false + 'SQL' | 'DELETE FROM users where id = 10' | true | true | false + 'SQL' | 'INSERT INTO project_ci_cd_settings (project_id) SELECT id FROM projects' | true | true | false + 'SQL' | 'UPDATE users SET admin = true WHERE id = 10' | true | true | false + 'CACHE' | 'SELECT * FROM users WHERE id = 10' | true | false | true + 'SCHEMA' | "SELECT attr.attname FROM pg_attribute attr INNER JOIN pg_constraint cons ON attr.attrelid = cons.conrelid AND attr.attnum = any(cons.conkey) WHERE cons.contype = 'p' AND cons.conrelid = '\"projects\"'::regclass" | false | false | false + nil | 'BEGIN' | false | false | false + nil | 'COMMIT' | false | false | false end - describe 'with a current transaction' do - it 'observes sql_duration metric' do - expect(subscriber).to receive(:current_transaction) - .at_least(:once) - .and_return(transaction) - expect(transaction).to receive(:observe).with(:gitlab_sql_duration_seconds, 0.002) - - subscriber.sql(event) - end + with_them do + let(:payload) { { name: name, sql: sql(sql_query, comments: comments), connection: connection } } it 'marks the current thread as using the database' do # since it would already have been toggled by other specs @@ -101,215 +53,20 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do expect { subscriber.sql(event) }.to change { Thread.current[:uses_db_connection] }.from(nil).to(true) end - context 'with read query' do - let(:expected_counters) do - { - db_count: 1, - db_write_count: 0, - db_cached_count: 0 - } - end - - it_behaves_like 'track query in metrics' - it_behaves_like 'track query in RequestStore' - - context 'with only select' do - let(:payload) { { sql: 'WITH active_milestones AS (SELECT COUNT(*), state FROM milestones GROUP BY state) SELECT * FROM active_milestones' } } - - it_behaves_like 'track query in metrics' - it_behaves_like 'track query in RequestStore' - end - end - - context 'write query' do - let(:expected_counters) do - { - db_count: 1, - db_write_count: 1, - db_cached_count: 0 - } - end - - context 'with select for update sql event' do - let(:payload) { { sql: 'SELECT * FROM users WHERE id = 10 FOR UPDATE' } } - - it_behaves_like 'track query in metrics' - it_behaves_like 'track query in RequestStore' - end - - context 'with common table expression' do - context 'with insert' do - let(:payload) { { sql: 'WITH archived_rows AS (SELECT * FROM users WHERE archived = true) INSERT INTO products_log SELECT * FROM archived_rows' } } - - it_behaves_like 'track query in metrics' - it_behaves_like 'track query in RequestStore' - end - end - - context 'with delete sql event' do - let(:payload) { { sql: 'DELETE FROM users where id = 10' } } - - it_behaves_like 'track query in metrics' - it_behaves_like 'track query in RequestStore' - end - - context 'with insert sql event' do - let(:payload) { { sql: 'INSERT INTO project_ci_cd_settings (project_id) SELECT id FROM projects' } } - - it_behaves_like 'track query in metrics' - it_behaves_like 'track query in RequestStore' - end - - context 'with update sql event' do - let(:payload) { { sql: 'UPDATE users SET admin = true WHERE id = 10' } } - - it_behaves_like 'track query in metrics' - it_behaves_like 'track query in RequestStore' - end - end - - context 'with cached query' do - let(:expected_counters) do - { - db_count: 1, - db_write_count: 0, - db_cached_count: 1 - } - end - - context 'with cached payload ' do - let(:payload) do - { - sql: 'SELECT * FROM users WHERE id = 10', - cached: true - } - end - - it_behaves_like 'track query in metrics' - it_behaves_like 'track query in RequestStore' - end - - context 'with cached payload name' do - let(:payload) do - { - sql: 'SELECT * FROM users WHERE id = 10', - name: 'CACHE' - } - end - - it_behaves_like 'track query in metrics' - it_behaves_like 'track query in RequestStore' - end - end - - context 'events are internal to Rails or irrelevant' do - let(:schema_event) do - double( - :event, - name: 'sql.active_record', - payload: { - sql: "SELECT attr.attname FROM pg_attribute attr INNER JOIN pg_constraint cons ON attr.attrelid = cons.conrelid AND attr.attnum = any(cons.conkey) WHERE cons.contype = 'p' AND cons.conrelid = '\"projects\"'::regclass", - name: 'SCHEMA', - connection_id: 135, - statement_name: nil, - binds: [] - }, - duration: 0.7 - ) - end - - let(:begin_event) do - double( - :event, - name: 'sql.active_record', - payload: { - sql: "BEGIN", - name: nil, - connection_id: 231, - statement_name: nil, - binds: [] - }, - duration: 1.1 - ) - end - - let(:commit_event) do - double( - :event, - name: 'sql.active_record', - payload: { - sql: "COMMIT", - name: nil, - connection_id: 212, - statement_name: nil, - binds: [] - }, - duration: 1.6 - ) - end - - it 'skips schema/begin/commit sql commands' do - allow(subscriber).to receive(:current_transaction) - .at_least(:once) - .and_return(transaction) - - expect(transaction).not_to receive(:increment) - - subscriber.sql(schema_event) - subscriber.sql(begin_event) - subscriber.sql(commit_event) - end - end + it_behaves_like 'record ActiveRecord metrics' + it_behaves_like 'store ActiveRecord info in RequestStore' end end - describe 'self.db_counter_payload' do - before do - allow(subscriber).to receive(:current_transaction) - .at_least(:once) - .and_return(transaction) - end - - context 'when RequestStore is enabled', :request_store do - context 'when query is executed' do - let(:expected_payload) do - { - db_count: 1, - db_cached_count: 0, - db_write_count: 0 - } - end - - it 'returns correct payload' do - subscriber.sql(event) - - expect(described_class.db_counter_payload).to eq(expected_payload) - end - end + context 'without Marginalia comments' do + let(:comments) { false } - context 'when query is not executed' do - let(:expected_payload) do - { - db_count: 0, - db_cached_count: 0, - db_write_count: 0 - } - end - - it 'returns correct payload' do - expect(described_class.db_counter_payload).to eq(expected_payload) - end - end - end - - context 'when RequestStore is disabled' do - let(:expected_payload) { {} } + it_behaves_like 'track generic sql events' + end - it 'returns empty payload' do - subscriber.sql(event) + context 'with Marginalia comments' do + let(:comments) { true } - expect(described_class.db_counter_payload).to eq(expected_payload) - end - end + it_behaves_like 'track generic sql events' end end diff --git a/spec/lib/gitlab/object_hierarchy_spec.rb b/spec/lib/gitlab/object_hierarchy_spec.rb index ef2d4fa0cbf..08e1a5ee0a3 100644 --- a/spec/lib/gitlab/object_hierarchy_spec.rb +++ b/spec/lib/gitlab/object_hierarchy_spec.rb @@ -7,178 +7,206 @@ RSpec.describe Gitlab::ObjectHierarchy do let!(:child1) { create(:group, parent: parent) } let!(:child2) { create(:group, parent: child1) } - describe '#base_and_ancestors' do - let(:relation) do - described_class.new(Group.where(id: child2.id)).base_and_ancestors - end - - it 'includes the base rows' do - expect(relation).to include(child2) - end + shared_context 'Gitlab::ObjectHierarchy test cases' do + describe '#base_and_ancestors' do + let(:relation) do + described_class.new(Group.where(id: child2.id)).base_and_ancestors + end - it 'includes all of the ancestors' do - expect(relation).to include(parent, child1) - end + it 'includes the base rows' do + expect(relation).to include(child2) + end - it 'can find ancestors upto a certain level' do - relation = described_class.new(Group.where(id: child2)).base_and_ancestors(upto: child1) + it 'includes all of the ancestors' do + expect(relation).to include(parent, child1) + end - expect(relation).to contain_exactly(child2) - end + it 'can find ancestors upto a certain level' do + relation = described_class.new(Group.where(id: child2)).base_and_ancestors(upto: child1) - it 'uses ancestors_base #initialize argument' do - relation = described_class.new(Group.where(id: child2.id), Group.none).base_and_ancestors + expect(relation).to contain_exactly(child2) + end - expect(relation).to include(parent, child1, child2) - end + it 'uses ancestors_base #initialize argument' do + relation = described_class.new(Group.where(id: child2.id), Group.none).base_and_ancestors - it 'does not allow the use of #update_all' do - expect { relation.update_all(share_with_group_lock: false) } - .to raise_error(ActiveRecord::ReadOnlyRecord) - end + expect(relation).to include(parent, child1, child2) + end - describe 'hierarchy_order option' do - let(:relation) do - described_class.new(Group.where(id: child2.id)).base_and_ancestors(hierarchy_order: hierarchy_order) + it 'does not allow the use of #update_all' do + expect { relation.update_all(share_with_group_lock: false) } + .to raise_error(ActiveRecord::ReadOnlyRecord) end - context ':asc' do - let(:hierarchy_order) { :asc } + describe 'hierarchy_order option' do + let(:relation) do + described_class.new(Group.where(id: child2.id)).base_and_ancestors(hierarchy_order: hierarchy_order) + end + + context ':asc' do + let(:hierarchy_order) { :asc } - it 'orders by child to parent' do - expect(relation).to eq([child2, child1, parent]) + it 'orders by child to parent' do + expect(relation).to eq([child2, child1, parent]) + end end - end - context ':desc' do - let(:hierarchy_order) { :desc } + context ':desc' do + let(:hierarchy_order) { :desc } - it 'orders by parent to child' do - expect(relation).to eq([parent, child1, child2]) + it 'orders by parent to child' do + expect(relation).to eq([parent, child1, child2]) + end end end end - end - - describe '#base_and_descendants' do - let(:relation) do - described_class.new(Group.where(id: parent.id)).base_and_descendants - end - it 'includes the base rows' do - expect(relation).to include(parent) - end + describe '#base_and_descendants' do + let(:relation) do + described_class.new(Group.where(id: parent.id)).base_and_descendants + end - it 'includes all the descendants' do - expect(relation).to include(child1, child2) - end + it 'includes the base rows' do + expect(relation).to include(parent) + end - it 'uses descendants_base #initialize argument' do - relation = described_class.new(Group.none, Group.where(id: parent.id)).base_and_descendants + it 'includes all the descendants' do + expect(relation).to include(child1, child2) + end - expect(relation).to include(parent, child1, child2) - end + it 'uses descendants_base #initialize argument' do + relation = described_class.new(Group.none, Group.where(id: parent.id)).base_and_descendants - it 'does not allow the use of #update_all' do - expect { relation.update_all(share_with_group_lock: false) } - .to raise_error(ActiveRecord::ReadOnlyRecord) - end + expect(relation).to include(parent, child1, child2) + end - context 'when with_depth is true' do - let(:relation) do - described_class.new(Group.where(id: parent.id)).base_and_descendants(with_depth: true) + it 'does not allow the use of #update_all' do + expect { relation.update_all(share_with_group_lock: false) } + .to raise_error(ActiveRecord::ReadOnlyRecord) end - it 'includes depth in the results' do - object_depths = { - parent.id => 1, - child1.id => 2, - child2.id => 3 - } + context 'when with_depth is true' do + let(:relation) do + described_class.new(Group.where(id: parent.id)).base_and_descendants(with_depth: true) + end + + it 'includes depth in the results' do + object_depths = { + parent.id => 1, + child1.id => 2, + child2.id => 3 + } - relation.each do |object| - expect(object.depth).to eq(object_depths[object.id]) + relation.each do |object| + expect(object.depth).to eq(object_depths[object.id]) + end end end end - end - describe '#descendants' do - it 'includes only the descendants' do - relation = described_class.new(Group.where(id: parent)).descendants + describe '#descendants' do + it 'includes only the descendants' do + relation = described_class.new(Group.where(id: parent)).descendants - expect(relation).to contain_exactly(child1, child2) + expect(relation).to contain_exactly(child1, child2) + end end - end - describe '#max_descendants_depth' do - subject { described_class.new(base_relation).max_descendants_depth } + describe '#max_descendants_depth' do + subject { described_class.new(base_relation).max_descendants_depth } - context 'when base relation is empty' do - let(:base_relation) { Group.where(id: nil) } + context 'when base relation is empty' do + let(:base_relation) { Group.where(id: nil) } - it { expect(subject).to be_nil } - end + it { expect(subject).to be_nil } + end - context 'when base has no children' do - let(:base_relation) { Group.where(id: child2) } + context 'when base has no children' do + let(:base_relation) { Group.where(id: child2) } - it { expect(subject).to eq(1) } - end + it { expect(subject).to eq(1) } + end - context 'when base has grandchildren' do - let(:base_relation) { Group.where(id: parent) } + context 'when base has grandchildren' do + let(:base_relation) { Group.where(id: parent) } - it { expect(subject).to eq(3) } + it { expect(subject).to eq(3) } + end end - end - describe '#ancestors' do - it 'includes only the ancestors' do - relation = described_class.new(Group.where(id: child2)).ancestors + describe '#ancestors' do + it 'includes only the ancestors' do + relation = described_class.new(Group.where(id: child2)).ancestors - expect(relation).to contain_exactly(child1, parent) - end + expect(relation).to contain_exactly(child1, parent) + end - it 'can find ancestors upto a certain level' do - relation = described_class.new(Group.where(id: child2)).ancestors(upto: child1) + it 'can find ancestors upto a certain level' do + relation = described_class.new(Group.where(id: child2)).ancestors(upto: child1) - expect(relation).to be_empty + expect(relation).to be_empty + end end - end - describe '#all_objects' do - let(:relation) do - described_class.new(Group.where(id: child1.id)).all_objects - end + describe '#all_objects' do + let(:relation) do + described_class.new(Group.where(id: child1.id)).all_objects + end - it 'includes the base rows' do - expect(relation).to include(child1) - end + it 'includes the base rows' do + expect(relation).to include(child1) + end + + it 'includes the ancestors' do + expect(relation).to include(parent) + end + + it 'includes the descendants' do + expect(relation).to include(child2) + end + + it 'uses ancestors_base #initialize argument for ancestors' do + relation = described_class.new(Group.where(id: child1.id), Group.where(id: non_existing_record_id)).all_objects + + expect(relation).to include(parent) + end - it 'includes the ancestors' do - expect(relation).to include(parent) + it 'uses descendants_base #initialize argument for descendants' do + relation = described_class.new(Group.where(id: non_existing_record_id), Group.where(id: child1.id)).all_objects + + expect(relation).to include(child2) + end + + it 'does not allow the use of #update_all' do + expect { relation.update_all(share_with_group_lock: false) } + .to raise_error(ActiveRecord::ReadOnlyRecord) + end end + end - it 'includes the descendants' do - expect(relation).to include(child2) + context 'when the use_distinct_in_object_hierarchy feature flag is enabled' do + before do + stub_feature_flags(use_distinct_in_object_hierarchy: true) end - it 'uses ancestors_base #initialize argument for ancestors' do - relation = described_class.new(Group.where(id: child1.id), Group.where(id: non_existing_record_id)).all_objects + it_behaves_like 'Gitlab::ObjectHierarchy test cases' - expect(relation).to include(parent) + it 'calls DISTINCT' do + expect(parent.self_and_descendants.to_sql).to include("DISTINCT") + expect(child2.self_and_ancestors.to_sql).to include("DISTINCT") end + end - it 'uses descendants_base #initialize argument for descendants' do - relation = described_class.new(Group.where(id: non_existing_record_id), Group.where(id: child1.id)).all_objects - - expect(relation).to include(child2) + context 'when the use_distinct_in_object_hierarchy feature flag is disabled' do + before do + stub_feature_flags(use_distinct_in_object_hierarchy: false) end - it 'does not allow the use of #update_all' do - expect { relation.update_all(share_with_group_lock: false) } - .to raise_error(ActiveRecord::ReadOnlyRecord) + it_behaves_like 'Gitlab::ObjectHierarchy test cases' + + it 'does not call DISTINCT' do + expect(parent.self_and_descendants.to_sql).not_to include("DISTINCT") + expect(child2.self_and_ancestors.to_sql).not_to include("DISTINCT") end end end diff --git a/spec/lib/gitlab/optimistic_locking_spec.rb b/spec/lib/gitlab/optimistic_locking_spec.rb index 0862a9c880e..1d669573b74 100644 --- a/spec/lib/gitlab/optimistic_locking_spec.rb +++ b/spec/lib/gitlab/optimistic_locking_spec.rb @@ -5,37 +5,108 @@ require 'spec_helper' RSpec.describe Gitlab::OptimisticLocking do let!(:pipeline) { create(:ci_pipeline) } let!(:pipeline2) { Ci::Pipeline.find(pipeline.id) } + let(:histogram) { spy('prometheus metric') } + + before do + allow(described_class) + .to receive(:retry_lock_histogram) + .and_return(histogram) + end describe '#retry_lock' do - it 'does not reload object if state changes' do - expect(pipeline).not_to receive(:reset) - expect(pipeline).to receive(:succeed).and_call_original + let(:name) { 'optimistic_locking_spec' } - described_class.retry_lock(pipeline) do |subject| - subject.succeed + context 'when state changed successfully without retries' do + subject do + described_class.retry_lock(pipeline, name: name) do |lock_subject| + lock_subject.succeed + end end - end - it 'retries action if exception is raised' do - pipeline.succeed + it 'does not reload object' do + expect(pipeline).not_to receive(:reset) + expect(pipeline).to receive(:succeed).and_call_original + + subject + end + + it 'does not create log record' do + expect(described_class.retry_lock_logger).not_to receive(:info) + + subject + end - expect(pipeline2).to receive(:reset).and_call_original - expect(pipeline2).to receive(:drop).twice.and_call_original + it 'adds number of retries to histogram' do + subject - described_class.retry_lock(pipeline2) do |subject| - subject.drop + expect(histogram).to have_received(:observe).with({}, 0) end end - it 'raises exception when too many retries' do - expect(pipeline).to receive(:drop).twice.and_call_original + context 'when at least one retry happened, the change succeeded' do + subject do + described_class.retry_lock(pipeline2, name: 'optimistic_locking_spec') do |lock_subject| + lock_subject.drop + end + end + + before do + pipeline.succeed + end + + it 'completes the action' do + expect(pipeline2).to receive(:reset).and_call_original + expect(pipeline2).to receive(:drop).twice.and_call_original + + subject + end + + it 'creates a single log record' do + expect(described_class.retry_lock_logger) + .to receive(:info) + .once + .with(hash_including(:time_s, name: name, retries: 1)) - expect do - described_class.retry_lock(pipeline, 1) do |subject| - subject.lock_version = 100 - subject.drop + subject + end + + it 'adds number of retries to histogram' do + subject + + expect(histogram).to have_received(:observe).with({}, 1) + end + end + + context 'when MAX_RETRIES attempts exceeded' do + subject do + described_class.retry_lock(pipeline, max_retries, name: name) do |lock_subject| + lock_subject.lock_version = 100 + lock_subject.drop end - end.to raise_error(ActiveRecord::StaleObjectError) + end + + let(:max_retries) { 2 } + + it 'raises an exception' do + expect(pipeline).to receive(:drop).exactly(max_retries + 1).times.and_call_original + + expect { subject }.to raise_error(ActiveRecord::StaleObjectError) + end + + it 'creates a single log record' do + expect(described_class.retry_lock_logger) + .to receive(:info) + .once + .with(hash_including(:time_s, name: name, retries: max_retries)) + + expect { subject }.to raise_error(ActiveRecord::StaleObjectError) + end + + it 'adds number of retries to histogram' do + expect { subject }.to raise_error(ActiveRecord::StaleObjectError) + + expect(histogram).to have_received(:observe).with({}, max_retries) + end end end diff --git a/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb b/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb new file mode 100644 index 00000000000..6e9e987f90c --- /dev/null +++ b/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Pagination::Keyset::ColumnOrderDefinition do + let_it_be(:project_name_column) do + described_class.new( + attribute_name: :name, + order_expression: Project.arel_table[:name].asc, + nullable: :not_nullable, + distinct: true + ) + end + + let_it_be(:project_name_lower_column) do + described_class.new( + attribute_name: :name, + order_expression: Project.arel_table[:name].lower.desc, + nullable: :not_nullable, + distinct: true + ) + end + + let_it_be(:project_calculated_column_expression) do + # COALESCE("projects"."description", 'No Description') + Arel::Nodes::NamedFunction.new('COALESCE', [ + Project.arel_table[:description], + Arel.sql("'No Description'") + ]) + end + + let_it_be(:project_calculated_column) do + described_class.new( + attribute_name: :name, + column_expression: project_calculated_column_expression, + order_expression: project_calculated_column_expression.asc, + nullable: :not_nullable, + distinct: true + ) + end + + describe '#order_direction' do + context 'inferring order_direction from order_expression' do + it { expect(project_name_column).to be_ascending_order } + it { expect(project_name_column).not_to be_descending_order } + + it { expect(project_name_lower_column).to be_descending_order } + it { expect(project_name_lower_column).not_to be_ascending_order } + + it { expect(project_calculated_column).to be_ascending_order } + it { expect(project_calculated_column).not_to be_descending_order } + + it 'raises error when order direction cannot be infered' do + expect do + described_class.new( + attribute_name: :name, + column_expression: Project.arel_table[:name], + order_expression: 'name asc', + reversed_order_expression: 'name desc', + nullable: :not_nullable, + distinct: true + ) + end.to raise_error(RuntimeError, /Invalid or missing `order_direction`/) + end + + it 'does not raise error when order direction is explicitly given' do + column_order_definition = described_class.new( + attribute_name: :name, + column_expression: Project.arel_table[:name], + order_expression: 'name asc', + reversed_order_expression: 'name desc', + order_direction: :asc, + nullable: :not_nullable, + distinct: true + ) + + expect(column_order_definition).to be_ascending_order + end + end + end + + describe '#column_expression' do + context 'inferring column_expression from order_expression' do + it 'infers the correct column expression' do + column_order_definition = described_class.new(attribute_name: :name, order_expression: Project.arel_table[:name].asc) + + expect(column_order_definition.column_expression).to eq(Project.arel_table[:name]) + end + + it 'raises error when raw string is given as order expression' do + expect do + described_class.new(attribute_name: :name, order_expression: 'name DESC') + end.to raise_error(RuntimeError, /Couldn't calculate the column expression. Please pass an ARel node/) + end + end + end + + describe '#reversed_order_expression' do + it 'raises error when order cannot be reversed automatically' do + expect do + described_class.new( + attribute_name: :name, + column_expression: Project.arel_table[:name], + order_expression: 'name asc', + order_direction: :asc, + nullable: :not_nullable, + distinct: true + ) + end.to raise_error(RuntimeError, /Couldn't determine reversed order/) + end + end + + describe '#reverse' do + it { expect(project_name_column.reverse.order_expression).to eq(Project.arel_table[:name].desc) } + it { expect(project_name_column.reverse).to be_descending_order } + + it { expect(project_calculated_column.reverse.order_expression).to eq(project_calculated_column_expression.desc) } + it { expect(project_calculated_column.reverse).to be_descending_order } + + context 'when reversed_order_expression is given' do + it 'uses the given expression' do + column_order_definition = described_class.new( + attribute_name: :name, + column_expression: Project.arel_table[:name], + order_expression: 'name asc', + reversed_order_expression: 'name desc', + order_direction: :asc, + nullable: :not_nullable, + distinct: true + ) + + expect(column_order_definition.reverse.order_expression).to eq('name desc') + end + end + end + + describe '#nullable' do + context 'when the column is nullable' do + let(:nulls_last_order) do + described_class.new( + attribute_name: :name, + column_expression: Project.arel_table[:name], + order_expression: Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', :desc), + reversed_order_expression: Gitlab::Database.nulls_first_order('merge_request_metrics.merged_at', :asc), + order_direction: :desc, + nullable: :nulls_last, # null values are always last + distinct: false + ) + end + + it 'requires the position of the null values in the result' do + expect(nulls_last_order).to be_nulls_last + end + + it 'reverses nullable correctly' do + expect(nulls_last_order.reverse).to be_nulls_first + end + + it 'raises error when invalid nullable value is given' do + expect do + described_class.new( + attribute_name: :name, + column_expression: Project.arel_table[:name], + order_expression: Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', :desc), + reversed_order_expression: Gitlab::Database.nulls_first_order('merge_request_metrics.merged_at', :asc), + order_direction: :desc, + nullable: true, + distinct: false + ) + end.to raise_error(RuntimeError, /Invalid `nullable` is given/) + end + + it 'raises error when the column is nullable and distinct' do + expect do + described_class.new( + attribute_name: :name, + column_expression: Project.arel_table[:name], + order_expression: Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', :desc), + reversed_order_expression: Gitlab::Database.nulls_first_order('merge_request_metrics.merged_at', :asc), + order_direction: :desc, + nullable: :nulls_last, + distinct: true + ) + end.to raise_error(RuntimeError, /Invalid column definition/) + end + end + end +end diff --git a/spec/lib/gitlab/pagination/keyset/order_spec.rb b/spec/lib/gitlab/pagination/keyset/order_spec.rb new file mode 100644 index 00000000000..665f790ee47 --- /dev/null +++ b/spec/lib/gitlab/pagination/keyset/order_spec.rb @@ -0,0 +1,420 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Pagination::Keyset::Order do + let(:table) { Arel::Table.new(:my_table) } + let(:order) { nil } + + def run_query(query) + ActiveRecord::Base.connection.execute(query).to_a + end + + def build_query(order:, where_conditions: nil, limit: nil) + <<-SQL + SELECT id, year, month + FROM (#{table_data}) my_table (id, year, month) + WHERE #{where_conditions || '1=1'} + ORDER BY #{order} + LIMIT #{limit || 999}; + SQL + end + + def iterate_and_collect(order:, page_size:, where_conditions: nil) + all_items = [] + + loop do + paginated_items = run_query(build_query(order: order, where_conditions: where_conditions, limit: page_size)) + break if paginated_items.empty? + + all_items.concat(paginated_items) + last_item = paginated_items.last + cursor_attributes = order.cursor_attributes_for_node(last_item) + where_conditions = order.build_where_values(cursor_attributes).to_sql + end + + all_items + end + + subject do + run_query(build_query(order: order)) + end + + shared_examples 'order examples' do + it { expect(subject).to eq(expected) } + + context 'when paginating forwards' do + subject { iterate_and_collect(order: order, page_size: 2) } + + it { expect(subject).to eq(expected) } + + context 'with different page size' do + subject { iterate_and_collect(order: order, page_size: 5) } + + it { expect(subject).to eq(expected) } + end + end + + context 'when paginating backwards' do + subject do + last_item = expected.last + cursor_attributes = order.cursor_attributes_for_node(last_item) + where_conditions = order.reversed_order.build_where_values(cursor_attributes) + + iterate_and_collect(order: order.reversed_order, page_size: 2, where_conditions: where_conditions.to_sql) + end + + it do + expect(subject).to eq(expected.reverse[1..-1]) # removing one item because we used it to calculate cursor data for the "last" page in subject + end + end + end + + context 'when ordering by a distinct column' do + let(:table_data) do + <<-SQL + VALUES (1, 0, 0), + (2, 0, 0), + (3, 0, 0), + (4, 0, 0), + (5, 0, 0), + (6, 0, 0), + (7, 0, 0), + (8, 0, 0), + (9, 0, 0) + SQL + end + + let(:order) do + Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + column_expression: table['id'], + order_expression: table['id'].desc, + nullable: :not_nullable, + distinct: true + ) + ]) + end + + let(:expected) do + [ + { "id" => 9, "year" => 0, "month" => 0 }, + { "id" => 8, "year" => 0, "month" => 0 }, + { "id" => 7, "year" => 0, "month" => 0 }, + { "id" => 6, "year" => 0, "month" => 0 }, + { "id" => 5, "year" => 0, "month" => 0 }, + { "id" => 4, "year" => 0, "month" => 0 }, + { "id" => 3, "year" => 0, "month" => 0 }, + { "id" => 2, "year" => 0, "month" => 0 }, + { "id" => 1, "year" => 0, "month" => 0 } + ] + end + + it_behaves_like 'order examples' + end + + context 'when ordering by two non-nullable columns and a distinct column' do + let(:table_data) do + <<-SQL + VALUES (1, 2010, 2), + (2, 2011, 1), + (3, 2009, 2), + (4, 2011, 1), + (5, 2011, 1), + (6, 2009, 2), + (7, 2010, 3), + (8, 2012, 4), + (9, 2013, 5) + SQL + end + + let(:order) do + Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'year', + column_expression: table['year'], + order_expression: table['year'].asc, + nullable: :not_nullable, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'month', + column_expression: table['month'], + order_expression: table['month'].asc, + nullable: :not_nullable, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + column_expression: table['id'], + order_expression: table['id'].asc, + nullable: :not_nullable, + distinct: true + ) + ]) + end + + let(:expected) do + [ + { 'year' => 2009, 'month' => 2, 'id' => 3 }, + { 'year' => 2009, 'month' => 2, 'id' => 6 }, + { 'year' => 2010, 'month' => 2, 'id' => 1 }, + { 'year' => 2010, 'month' => 3, 'id' => 7 }, + { 'year' => 2011, 'month' => 1, 'id' => 2 }, + { 'year' => 2011, 'month' => 1, 'id' => 4 }, + { 'year' => 2011, 'month' => 1, 'id' => 5 }, + { 'year' => 2012, 'month' => 4, 'id' => 8 }, + { 'year' => 2013, 'month' => 5, 'id' => 9 } + ] + end + + it_behaves_like 'order examples' + end + + context 'when ordering by nullable columns and a distinct column' do + let(:table_data) do + <<-SQL + VALUES (1, 2010, null), + (2, 2011, 2), + (3, null, null), + (4, null, 5), + (5, 2010, null), + (6, 2011, 2), + (7, 2010, 2), + (8, 2012, 2), + (9, null, 2), + (10, null, null), + (11, 2010, 2) + SQL + end + + let(:order) do + Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'year', + column_expression: table['year'], + order_expression: Gitlab::Database.nulls_last_order('year', :asc), + reversed_order_expression: Gitlab::Database.nulls_first_order('year', :desc), + order_direction: :asc, + nullable: :nulls_last, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'month', + column_expression: table['month'], + order_expression: Gitlab::Database.nulls_last_order('month', :asc), + reversed_order_expression: Gitlab::Database.nulls_first_order('month', :desc), + order_direction: :asc, + nullable: :nulls_last, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + column_expression: table['id'], + order_expression: table['id'].asc, + nullable: :not_nullable, + distinct: true + ) + ]) + end + + let(:expected) do + [ + { "id" => 7, "year" => 2010, "month" => 2 }, + { "id" => 11, "year" => 2010, "month" => 2 }, + { "id" => 1, "year" => 2010, "month" => nil }, + { "id" => 5, "year" => 2010, "month" => nil }, + { "id" => 2, "year" => 2011, "month" => 2 }, + { "id" => 6, "year" => 2011, "month" => 2 }, + { "id" => 8, "year" => 2012, "month" => 2 }, + { "id" => 9, "year" => nil, "month" => 2 }, + { "id" => 4, "year" => nil, "month" => 5 }, + { "id" => 3, "year" => nil, "month" => nil }, + { "id" => 10, "year" => nil, "month" => nil } + ] + end + + it_behaves_like 'order examples' + end + + context 'when ordering by nullable columns with nulls first ordering and a distinct column' do + let(:table_data) do + <<-SQL + VALUES (1, 2010, null), + (2, 2011, 2), + (3, null, null), + (4, null, 5), + (5, 2010, null), + (6, 2011, 2), + (7, 2010, 2), + (8, 2012, 2), + (9, null, 2), + (10, null, null), + (11, 2010, 2) + SQL + end + + let(:order) do + Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'year', + column_expression: table['year'], + order_expression: Gitlab::Database.nulls_first_order('year', :asc), + reversed_order_expression: Gitlab::Database.nulls_last_order('year', :desc), + order_direction: :asc, + nullable: :nulls_first, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'month', + column_expression: table['month'], + order_expression: Gitlab::Database.nulls_first_order('month', :asc), + order_direction: :asc, + reversed_order_expression: Gitlab::Database.nulls_last_order('month', :desc), + nullable: :nulls_first, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + column_expression: table['id'], + order_expression: table['id'].asc, + nullable: :not_nullable, + distinct: true + ) + ]) + end + + let(:expected) do + [ + { "id" => 3, "year" => nil, "month" => nil }, + { "id" => 10, "year" => nil, "month" => nil }, + { "id" => 9, "year" => nil, "month" => 2 }, + { "id" => 4, "year" => nil, "month" => 5 }, + { "id" => 1, "year" => 2010, "month" => nil }, + { "id" => 5, "year" => 2010, "month" => nil }, + { "id" => 7, "year" => 2010, "month" => 2 }, + { "id" => 11, "year" => 2010, "month" => 2 }, + { "id" => 2, "year" => 2011, "month" => 2 }, + { "id" => 6, "year" => 2011, "month" => 2 }, + { "id" => 8, "year" => 2012, "month" => 2 } + ] + end + + it_behaves_like 'order examples' + end + + context 'when ordering by non-nullable columns with mixed directions and a distinct column' do + let(:table_data) do + <<-SQL + VALUES (1, 2010, 0), + (2, 2011, 0), + (3, 2010, 0), + (4, 2010, 0), + (5, 2012, 0), + (6, 2012, 0), + (7, 2010, 0), + (8, 2011, 0), + (9, 2013, 0), + (10, 2014, 0), + (11, 2013, 0) + SQL + end + + let(:order) do + Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'year', + column_expression: table['year'], + order_expression: table['year'].asc, + nullable: :not_nullable, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + column_expression: table['id'], + order_expression: table['id'].desc, + nullable: :not_nullable, + distinct: true + ) + ]) + end + + let(:expected) do + [ + { "id" => 7, "year" => 2010, "month" => 0 }, + { "id" => 4, "year" => 2010, "month" => 0 }, + { "id" => 3, "year" => 2010, "month" => 0 }, + { "id" => 1, "year" => 2010, "month" => 0 }, + { "id" => 8, "year" => 2011, "month" => 0 }, + { "id" => 2, "year" => 2011, "month" => 0 }, + { "id" => 6, "year" => 2012, "month" => 0 }, + { "id" => 5, "year" => 2012, "month" => 0 }, + { "id" => 11, "year" => 2013, "month" => 0 }, + { "id" => 9, "year" => 2013, "month" => 0 }, + { "id" => 10, "year" => 2014, "month" => 0 } + ] + end + + it 'takes out a slice between two cursors' do + after_cursor = { "id" => 8, "year" => 2011 } + before_cursor = { "id" => 5, "year" => 2012 } + + after_conditions = order.build_where_values(after_cursor) + reversed = order.reversed_order + before_conditions = reversed.build_where_values(before_cursor) + + query = build_query(order: order, where_conditions: "(#{after_conditions.to_sql}) AND (#{before_conditions.to_sql})", limit: 100) + + expect(run_query(query)).to eq([ + { "id" => 2, "year" => 2011, "month" => 0 }, + { "id" => 6, "year" => 2012, "month" => 0 } + ]) + end + end + + context 'when the passed cursor values do not match with the order definition' do + let(:order) do + Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'year', + column_expression: table['year'], + order_expression: table['year'].asc, + nullable: :not_nullable, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + column_expression: table['id'], + order_expression: table['id'].desc, + nullable: :not_nullable, + distinct: true + ) + ]) + end + + context 'when values are missing' do + it 'raises error' do + expect { order.build_where_values(id: 1) }.to raise_error(/Missing items: year/) + end + end + + context 'when extra values are present' do + it 'raises error' do + expect { order.build_where_values(id: 1, year: 2, foo: 3) }.to raise_error(/Extra items: foo/) + end + end + + context 'when values are missing and extra values are present' do + it 'raises error' do + expect { order.build_where_values(year: 2, foo: 3) }.to raise_error(/Extra items: foo\. Missing items: id/) + end + end + + context 'when no values are passed' do + it 'returns nil' do + expect(order.build_where_values({})).to eq(nil) + end + end + end +end diff --git a/spec/lib/gitlab/query_limiting/active_support_subscriber_spec.rb b/spec/lib/gitlab/query_limiting/active_support_subscriber_spec.rb index a8dd482c7b8..1ab8e22d6d1 100644 --- a/spec/lib/gitlab/query_limiting/active_support_subscriber_spec.rb +++ b/spec/lib/gitlab/query_limiting/active_support_subscriber_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Gitlab::QueryLimiting::ActiveSupportSubscriber do - let(:transaction) { instance_double(Gitlab::QueryLimiting::Transaction, increment: true) } + let(:transaction) { instance_double(Gitlab::QueryLimiting::Transaction, executed_sql: true, increment: true) } before do allow(Gitlab::QueryLimiting::Transaction) @@ -18,6 +18,11 @@ RSpec.describe Gitlab::QueryLimiting::ActiveSupportSubscriber do expect(transaction) .to have_received(:increment) .once + + expect(transaction) + .to have_received(:executed_sql) + .once + .with(String) end context 'when the query is actually a rails cache hit' do @@ -30,6 +35,11 @@ RSpec.describe Gitlab::QueryLimiting::ActiveSupportSubscriber do expect(transaction) .to have_received(:increment) .once + + expect(transaction) + .to have_received(:executed_sql) + .once + .with(String) end end end diff --git a/spec/lib/gitlab/query_limiting/transaction_spec.rb b/spec/lib/gitlab/query_limiting/transaction_spec.rb index 331c3c1d8b0..40804736b86 100644 --- a/spec/lib/gitlab/query_limiting/transaction_spec.rb +++ b/spec/lib/gitlab/query_limiting/transaction_spec.rb @@ -118,6 +118,30 @@ RSpec.describe Gitlab::QueryLimiting::Transaction do ) end + it 'includes a list of executed queries' do + transaction = described_class.new + transaction.count = max = described_class::THRESHOLD + %w[foo bar baz].each { |sql| transaction.executed_sql(sql) } + + message = transaction.error_message + + expect(message).to start_with( + "Too many SQL queries were executed: a maximum of #{max} " \ + "is allowed but #{max} SQL queries were executed" + ) + + expect(message).to include("0: foo", "1: bar", "2: baz") + end + + it 'indicates if the log is truncated' do + transaction = described_class.new + transaction.count = described_class::THRESHOLD * 2 + + message = transaction.error_message + + expect(message).to end_with('...') + end + it 'includes the action name in the error message when present' do transaction = described_class.new transaction.count = max = described_class::THRESHOLD diff --git a/spec/lib/gitlab/query_limiting_spec.rb b/spec/lib/gitlab/query_limiting_spec.rb index 0fcd865567d..4f70c65adca 100644 --- a/spec/lib/gitlab/query_limiting_spec.rb +++ b/spec/lib/gitlab/query_limiting_spec.rb @@ -63,6 +63,20 @@ RSpec.describe Gitlab::QueryLimiting do expect(transaction.count).to eq(before) end + + it 'whitelists when enabled' do + described_class.whitelist('https://example.com') + + expect(transaction.whitelisted).to eq(true) + end + + it 'does not whitelist when disabled' do + allow(described_class).to receive(:enable?).and_return(false) + + described_class.whitelist('https://example.com') + + expect(transaction.whitelisted).to eq(false) + end end end end diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb index 776ca81a338..1aca3dae41b 100644 --- a/spec/lib/gitlab/regex_spec.rb +++ b/spec/lib/gitlab/regex_spec.rb @@ -367,6 +367,35 @@ RSpec.describe Gitlab::Regex do it { is_expected.not_to match('%2e%2e%2f1.2.3') } end + describe '.npm_package_name_regex' do + subject { described_class.npm_package_name_regex } + + it { is_expected.to match('@scope/package') } + it { is_expected.to match('unscoped-package') } + it { is_expected.not_to match('@first-scope@second-scope/package') } + it { is_expected.not_to match('scope-without-at-symbol/package') } + it { is_expected.not_to match('@not-a-scoped-package') } + it { is_expected.not_to match('@scope/sub/package') } + it { is_expected.not_to match('@scope/../../package') } + it { is_expected.not_to match('@scope%2e%2e%2fpackage') } + it { is_expected.not_to match('@%2e%2e%2f/package') } + + context 'capturing group' do + [ + ['@scope/package', 'scope'], + ['unscoped-package', nil], + ['@not-a-scoped-package', nil], + ['@scope/sub/package', nil], + ['@inv@lid-scope/package', nil] + ].each do |package_name, extracted_scope_name| + it "extracts the scope name for #{package_name}" do + match = package_name.match(described_class.npm_package_name_regex) + expect(match&.captures&.first).to eq(extracted_scope_name) + end + end + end + end + describe '.nuget_version_regex' do subject { described_class.nuget_version_regex } diff --git a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb index e58e41d3e4f..71f4f2a3b64 100644 --- a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' +# rubocop: disable RSpec/MultipleMemoizedHelpers RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do context "with worker attribution" do subject { described_class.new } @@ -112,6 +113,14 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do expect { |b| subject.call(worker, job, :test, &b) }.to yield_control.once end + it 'calls BackgroundTransaction' do + expect_next_instance_of(Gitlab::Metrics::BackgroundTransaction) do |instance| + expect(instance).to receive(:run) + end + + subject.call(worker, job, :test) {} + end + it 'sets queue specific metrics' do expect(running_jobs_metric).to receive(:increment).with(labels, -1) expect(running_jobs_metric).to receive(:increment).with(labels, 1) @@ -287,3 +296,4 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do end end end +# rubocop: enable RSpec/MultipleMemoizedHelpers diff --git a/spec/lib/gitlab/sidekiq_middleware/size_limiter/client_spec.rb b/spec/lib/gitlab/sidekiq_middleware/size_limiter/client_spec.rb new file mode 100644 index 00000000000..df8e47d60f0 --- /dev/null +++ b/spec/lib/gitlab/sidekiq_middleware/size_limiter/client_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Client, :clean_gitlab_redis_queues do + let(:worker_class) do + Class.new do + def self.name + "TestSizeLimiterWorker" + end + + include ApplicationWorker + + def perform(*args); end + end + end + + before do + stub_const("TestSizeLimiterWorker", worker_class) + end + + describe '#call' do + context 'when the validator rejects the job' do + before do + allow(Gitlab::SidekiqMiddleware::SizeLimiter::Validator).to receive(:validate!).and_raise( + Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError.new( + TestSizeLimiterWorker, 500, 300 + ) + ) + end + + it 'raises an exception when scheduling job with #perform_at' do + expect do + TestSizeLimiterWorker.perform_at(30.seconds.from_now, 1, 2, 3) + end.to raise_error Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError + end + + it 'raises an exception when scheduling job with #perform_async' do + expect do + TestSizeLimiterWorker.perform_async(1, 2, 3) + end.to raise_error Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError + end + + it 'raises an exception when scheduling job with #perform_in' do + expect do + TestSizeLimiterWorker.perform_in(3.seconds, 1, 2, 3) + end.to raise_error Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError + end + end + + context 'when the validator validates the job suscessfully' do + before do + # Do nothing + allow(Gitlab::SidekiqMiddleware::SizeLimiter::Client).to receive(:validate!) + end + + it 'raises an exception when scheduling job with #perform_at' do + expect do + TestSizeLimiterWorker.perform_at(30.seconds.from_now, 1, 2, 3) + end.not_to raise_error + + expect(TestSizeLimiterWorker.jobs).to contain_exactly( + a_hash_including( + "class" => "TestSizeLimiterWorker", + "args" => [1, 2, 3], + "at" => be_a(Float) + ) + ) + end + + it 'raises an exception when scheduling job with #perform_async' do + expect do + TestSizeLimiterWorker.perform_async(1, 2, 3) + end.not_to raise_error + + expect(TestSizeLimiterWorker.jobs).to contain_exactly( + a_hash_including( + "class" => "TestSizeLimiterWorker", + "args" => [1, 2, 3] + ) + ) + end + + it 'raises an exception when scheduling job with #perform_in' do + expect do + TestSizeLimiterWorker.perform_in(3.seconds, 1, 2, 3) + end.not_to raise_error + + expect(TestSizeLimiterWorker.jobs).to contain_exactly( + a_hash_including( + "class" => "TestSizeLimiterWorker", + "args" => [1, 2, 3], + "at" => be_a(Float) + ) + ) + end + end + end +end diff --git a/spec/lib/gitlab/sidekiq_middleware/size_limiter/exceed_limit_error_spec.rb b/spec/lib/gitlab/sidekiq_middleware/size_limiter/exceed_limit_error_spec.rb new file mode 100644 index 00000000000..75b1d9fd87e --- /dev/null +++ b/spec/lib/gitlab/sidekiq_middleware/size_limiter/exceed_limit_error_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError do + let(:worker_class) do + Class.new do + def self.name + "TestSizeLimiterWorker" + end + + include ApplicationWorker + + def perform(*args); end + end + end + + before do + stub_const("TestSizeLimiterWorker", worker_class) + end + + it 'encapsulates worker info' do + exception = described_class.new(TestSizeLimiterWorker, 500, 300) + + expect(exception.message).to eql("TestSizeLimiterWorker job exceeds payload size limit (500/300)") + expect(exception.worker_class).to eql(TestSizeLimiterWorker) + expect(exception.size).to be(500) + expect(exception.size_limit).to be(300) + expect(exception.sentry_extra_data).to eql( + worker_class: 'TestSizeLimiterWorker', + size: 500, + size_limit: 300 + ) + end +end diff --git a/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb b/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb new file mode 100644 index 00000000000..3140686c908 --- /dev/null +++ b/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb @@ -0,0 +1,253 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do + let(:worker_class) do + Class.new do + def self.name + "TestSizeLimiterWorker" + end + + include ApplicationWorker + + def perform(*args); end + end + end + + before do + stub_const("TestSizeLimiterWorker", worker_class) + end + + describe '#initialize' do + context 'when the input mode is valid' do + it 'does not log a warning message' do + expect(::Sidekiq.logger).not_to receive(:warn) + + described_class.new(TestSizeLimiterWorker, {}, mode: 'track') + described_class.new(TestSizeLimiterWorker, {}, mode: 'raise') + end + end + + context 'when the input mode is invalid' do + it 'defaults to track mode and logs a warning message' do + expect(::Sidekiq.logger).to receive(:warn).with('Invalid Sidekiq size limiter mode: invalid. Fallback to track mode.') + + validator = described_class.new(TestSizeLimiterWorker, {}, mode: 'invalid') + + expect(validator.mode).to eql('track') + end + end + + context 'when the input mode is empty' do + it 'defaults to track mode' do + expect(::Sidekiq.logger).not_to receive(:warn) + + validator = described_class.new(TestSizeLimiterWorker, {}) + + expect(validator.mode).to eql('track') + end + end + + context 'when the size input is valid' do + it 'does not log a warning message' do + expect(::Sidekiq.logger).not_to receive(:warn) + + described_class.new(TestSizeLimiterWorker, {}, size_limit: 300) + described_class.new(TestSizeLimiterWorker, {}, size_limit: 0) + end + end + + context 'when the size input is invalid' do + it 'defaults to 0 and logs a warning message' do + expect(::Sidekiq.logger).to receive(:warn).with('Invalid Sidekiq size limiter limit: -1') + + described_class.new(TestSizeLimiterWorker, {}, size_limit: -1) + end + end + + context 'when the size input is empty' do + it 'defaults to 0' do + expect(::Sidekiq.logger).not_to receive(:warn) + + validator = described_class.new(TestSizeLimiterWorker, {}) + + expect(validator.size_limit).to be(0) + end + end + end + + shared_examples 'validate limit job payload size' do + context 'in track mode' do + let(:mode) { 'track' } + + context 'when size limit negative' do + let(:size_limit) { -1 } + + it 'does not track jobs' do + expect(Gitlab::ErrorTracking).not_to receive(:track_exception) + + validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) + end + + it 'does not raise exception' do + expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error + end + end + + context 'when size limit is 0' do + let(:size_limit) { 0 } + + it 'does not track jobs' do + expect(Gitlab::ErrorTracking).not_to receive(:track_exception) + + validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) + end + + it 'does not raise exception' do + expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error + end + end + + context 'when job size is bigger than size limit' do + let(:size_limit) { 50 } + + it 'tracks job' do + expect(Gitlab::ErrorTracking).to receive(:track_exception).with( + be_a(Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError) + ) + + validate.call(TestSizeLimiterWorker, { a: 'a' * 100 }) + end + + it 'does not raise an exception' do + expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error + end + + context 'when the worker has big_payload attribute' do + before do + worker_class.big_payload! + end + + it 'does not track jobs' do + expect(Gitlab::ErrorTracking).not_to receive(:track_exception) + + validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) + validate.call('TestSizeLimiterWorker', { a: 'a' * 300 }) + end + + it 'does not raise an exception' do + expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error + expect { validate.call('TestSizeLimiterWorker', { a: 'a' * 300 }) }.not_to raise_error + end + end + end + + context 'when job size is less than size limit' do + let(:size_limit) { 50 } + + it 'does not track job' do + expect(Gitlab::ErrorTracking).not_to receive(:track_exception) + + validate.call(TestSizeLimiterWorker, { a: 'a' }) + end + + it 'does not raise an exception' do + expect { validate.call(TestSizeLimiterWorker, { a: 'a' }) }.not_to raise_error + end + end + end + + context 'in raise mode' do + let(:mode) { 'raise' } + + context 'when size limit is negative' do + let(:size_limit) { -1 } + + it 'does not raise exception' do + expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error + end + end + + context 'when size limit is 0' do + let(:size_limit) { 0 } + + it 'does not raise exception' do + expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error + end + end + + context 'when job size is bigger than size limit' do + let(:size_limit) { 50 } + + it 'raises an exception' do + expect do + validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) + end.to raise_error( + Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError, + /TestSizeLimiterWorker job exceeds payload size limit/i + ) + end + + context 'when the worker has big_payload attribute' do + before do + worker_class.big_payload! + end + + it 'does not raise an exception' do + expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error + expect { validate.call('TestSizeLimiterWorker', { a: 'a' * 300 }) }.not_to raise_error + end + end + end + + context 'when job size is less than size limit' do + let(:size_limit) { 50 } + + it 'does not raise an exception' do + expect { validate.call(TestSizeLimiterWorker, { a: 'a' }) }.not_to raise_error + end + end + end + end + + describe '#validate!' do + context 'when calling SizeLimiter.validate!' do + let(:validate) { ->(worker_clas, job) { described_class.validate!(worker_class, job) } } + + before do + stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_MODE', mode) + stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_LIMIT_BYTES', size_limit) + end + + it_behaves_like 'validate limit job payload size' + end + + context 'when creating an instance with the related ENV variables' do + let(:validate) do + ->(worker_clas, job) do + validator = described_class.new(worker_class, job, mode: mode, size_limit: size_limit) + validator.validate! + end + end + + before do + stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_MODE', mode) + stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_LIMIT_BYTES', size_limit) + end + + it_behaves_like 'validate limit job payload size' + end + + context 'when creating an instance with mode and size limit' do + let(:validate) do + ->(worker_clas, job) do + validator = described_class.new(worker_class, job, mode: mode, size_limit: size_limit) + validator.validate! + end + end + + it_behaves_like 'validate limit job payload size' + end + end +end diff --git a/spec/lib/gitlab/sidekiq_middleware_spec.rb b/spec/lib/gitlab/sidekiq_middleware_spec.rb index b632fc8bad2..755f6004e52 100644 --- a/spec/lib/gitlab/sidekiq_middleware_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware_spec.rb @@ -177,6 +177,7 @@ RSpec.describe Gitlab::SidekiqMiddleware do ::Gitlab::SidekiqMiddleware::DuplicateJobs::Client, ::Gitlab::SidekiqStatus::ClientMiddleware, ::Gitlab::SidekiqMiddleware::AdminMode::Client, + ::Gitlab::SidekiqMiddleware::SizeLimiter::Client, ::Gitlab::SidekiqMiddleware::ClientMetrics ] end diff --git a/spec/lib/gitlab/string_range_marker_spec.rb b/spec/lib/gitlab/string_range_marker_spec.rb index 52fab6e3109..6f63c8e2df4 100644 --- a/spec/lib/gitlab/string_range_marker_spec.rb +++ b/spec/lib/gitlab/string_range_marker_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Gitlab::StringRangeMarker do raw = 'abc <def>' inline_diffs = [2..5] - described_class.new(raw, rich).mark(inline_diffs) do |text, left:, right:| + described_class.new(raw, rich).mark(inline_diffs) do |text, left:, right:, mode:| "LEFT#{text}RIGHT".html_safe end end diff --git a/spec/lib/gitlab/string_regex_marker_spec.rb b/spec/lib/gitlab/string_regex_marker_spec.rb index 2dadd222820..a02be83558c 100644 --- a/spec/lib/gitlab/string_regex_marker_spec.rb +++ b/spec/lib/gitlab/string_regex_marker_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Gitlab::StringRegexMarker do let(:rich) { %{<span class="key">"name"</span><span class="punctuation">: </span><span class="value">"AFNetworking"</span>}.html_safe } subject do - described_class.new(raw, rich).mark(/"[^"]+":\s*"(?<name>[^"]+)"/, group: :name) do |text, left:, right:| + described_class.new(raw, rich).mark(/"[^"]+":\s*"(?<name>[^"]+)"/, group: :name) do |text, left:, right:, mode:| %{<a href="#">#{text}</a>}.html_safe end end @@ -25,7 +25,7 @@ RSpec.describe Gitlab::StringRegexMarker do let(:rich) { %{a <b> <c> d}.html_safe } subject do - described_class.new(raw, rich).mark(/<[a-z]>/) do |text, left:, right:| + described_class.new(raw, rich).mark(/<[a-z]>/) do |text, left:, right:, mode:| %{<strong>#{text}</strong>}.html_safe end end diff --git a/spec/lib/gitlab/tracking/standard_context_spec.rb b/spec/lib/gitlab/tracking/standard_context_spec.rb index 7a0a4f0cc46..561edbd38f8 100644 --- a/spec/lib/gitlab/tracking/standard_context_spec.rb +++ b/spec/lib/gitlab/tracking/standard_context_spec.rb @@ -22,7 +22,7 @@ RSpec.describe Gitlab::Tracking::StandardContext do context 'staging' do before do - allow(Gitlab).to receive(:staging?).and_return(true) + stub_config_setting(url: 'https://staging.gitlab.com') end include_examples 'contains environment', 'staging' @@ -30,11 +30,27 @@ RSpec.describe Gitlab::Tracking::StandardContext do context 'production' do before do - allow(Gitlab).to receive(:com_and_canary?).and_return(true) + stub_config_setting(url: 'https://gitlab.com') end include_examples 'contains environment', 'production' end + + context 'org' do + before do + stub_config_setting(url: 'https://dev.gitlab.org') + end + + include_examples 'contains environment', 'org' + end + + context 'other self-managed instance' do + before do + stub_rails_env('production') + end + + include_examples 'contains environment', 'self-managed' + end end it 'contains source' do diff --git a/spec/lib/gitlab/tracking_spec.rb b/spec/lib/gitlab/tracking_spec.rb index 80740c8112e..ac052bd7a80 100644 --- a/spec/lib/gitlab/tracking_spec.rb +++ b/spec/lib/gitlab/tracking_spec.rb @@ -61,8 +61,8 @@ RSpec.describe Gitlab::Tracking do expect(args[:property]).to eq('property') expect(args[:value]).to eq(1.5) expect(args[:context].length).to eq(2) - expect(args[:context].first).to eq(other_context) - expect(args[:context].last.to_json[:schema]).to eq(Gitlab::Tracking::StandardContext::GITLAB_STANDARD_SCHEMA_URL) + expect(args[:context].first.to_json[:schema]).to eq(Gitlab::Tracking::StandardContext::GITLAB_STANDARD_SCHEMA_URL) + expect(args[:context].last).to eq(other_context) end described_class.event('category', 'action', label: 'label', property: 'property', value: 1.5, diff --git a/spec/lib/gitlab/tree_summary_spec.rb b/spec/lib/gitlab/tree_summary_spec.rb index d2c5844b0fa..661ef507a82 100644 --- a/spec/lib/gitlab/tree_summary_spec.rb +++ b/spec/lib/gitlab/tree_summary_spec.rb @@ -57,14 +57,12 @@ RSpec.describe Gitlab::TreeSummary do context 'with caching', :use_clean_rails_memory_store_caching do subject { Rails.cache.fetch(key) } - before do - summarized - end - context 'Repository tree cache' do let(:key) { ['projects', project.id, 'content', commit.id, path] } it 'creates a cache for repository content' do + summarized + is_expected.to eq([{ file_name: 'a.txt', type: :blob }]) end end @@ -72,11 +70,34 @@ RSpec.describe Gitlab::TreeSummary do context 'Commits list cache' do let(:offset) { 0 } let(:limit) { 25 } - let(:key) { ['projects', project.id, 'last_commits_list', commit.id, path, offset, limit] } + let(:key) { ['projects', project.id, 'last_commits', commit.id, path, offset, limit] } it 'creates a cache for commits list' do + summarized + is_expected.to eq('a.txt' => commit.to_hash) end + + context 'when commit has a very long message' do + before do + repo.create_file( + project.creator, + 'long.txt', + '', + message: message, + branch_name: project.default_branch_or_master + ) + end + + let(:message) { 'a' * 1025 } + let(:expected_message) { message[0...1021] + '...' } + + it 'truncates commit message to 1 kilobyte' do + summarized + + is_expected.to include('long.txt' => a_hash_including(message: expected_message)) + end + end end end end diff --git a/spec/lib/gitlab/url_blocker_spec.rb b/spec/lib/gitlab/url_blocker_spec.rb index fa01d4e48df..e076815c4f6 100644 --- a/spec/lib/gitlab/url_blocker_spec.rb +++ b/spec/lib/gitlab/url_blocker_spec.rb @@ -166,7 +166,7 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do let(:ports) { Project::VALID_IMPORT_PORTS } it 'allows imports from configured web host and port' do - import_url = "http://#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}/t.git" + import_url = "http://#{Gitlab.host_with_port}/t.git" expect(described_class.blocked_url?(import_url)).to be false end @@ -190,7 +190,7 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do end it 'returns true for bad protocol on configured web/SSH host and ports' do - web_url = "javascript://#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}/t.git%0aalert(1)" + web_url = "javascript://#{Gitlab.host_with_port}/t.git%0aalert(1)" expect(described_class.blocked_url?(web_url)).to be true ssh_url = "javascript://#{Gitlab.config.gitlab_shell.ssh_host}:#{Gitlab.config.gitlab_shell.ssh_port}/t.git%0aalert(1)" diff --git a/spec/lib/gitlab/usage/docs/renderer_spec.rb b/spec/lib/gitlab/usage/docs/renderer_spec.rb index 0677aa2d9d7..f3b83a4a4b3 100644 --- a/spec/lib/gitlab/usage/docs/renderer_spec.rb +++ b/spec/lib/gitlab/usage/docs/renderer_spec.rb @@ -2,19 +2,21 @@ require 'spec_helper' +CODE_REGEX = %r{<code>(.*)</code>}.freeze + RSpec.describe Gitlab::Usage::Docs::Renderer do describe 'contents' do let(:dictionary_path) { Gitlab::Usage::Docs::Renderer::DICTIONARY_PATH } - let(:items) { Gitlab::Usage::MetricDefinition.definitions } + let(:items) { Gitlab::Usage::MetricDefinition.definitions.first(10).to_h } it 'generates dictionary for given items' do generated_dictionary = described_class.new(items).contents + generated_dictionary_keys = RDoc::Markdown .parse(generated_dictionary) .table_of_contents - .select { |metric_doc| metric_doc.level == 2 && !metric_doc.text.start_with?('info:') } - .map(&:text) - .map { |text| text.sub('<code>', '').sub('</code>', '') } + .select { |metric_doc| metric_doc.level == 3 } + .map { |item| item.text.match(CODE_REGEX)&.captures&.first } expect(generated_dictionary_keys).to match_array(items.keys) end diff --git a/spec/lib/gitlab/usage/docs/value_formatter_spec.rb b/spec/lib/gitlab/usage/docs/value_formatter_spec.rb index 7002c76a7cf..f21656df894 100644 --- a/spec/lib/gitlab/usage/docs/value_formatter_spec.rb +++ b/spec/lib/gitlab/usage/docs/value_formatter_spec.rb @@ -10,11 +10,11 @@ RSpec.describe Gitlab::Usage::Docs::ValueFormatter do :data_source | 'redis' | 'Redis' :data_source | 'ruby' | 'Ruby' :introduced_by_url | 'http://test.com' | '[Introduced by](http://test.com)' - :tier | %w(gold premium) | 'gold, premium' - :distribution | %w(ce ee) | 'ce, ee' + :tier | %w(gold premium) | ' `gold`, `premium`' + :distribution | %w(ce ee) | ' `ce`, `ee`' :key_path | 'key.path' | '**`key.path`**' :milestone | '13.4' | '13.4' - :status | 'data_available' | 'data_available' + :status | 'data_available' | '`data_available`' end with_them do diff --git a/spec/lib/gitlab/usage/metrics/aggregates/aggregate_spec.rb b/spec/lib/gitlab/usage/metrics/aggregates/aggregate_spec.rb index 5469ded18f9..7d8e3056384 100644 --- a/spec/lib/gitlab/usage/metrics/aggregates/aggregate_spec.rb +++ b/spec/lib/gitlab/usage/metrics/aggregates/aggregate_spec.rb @@ -9,10 +9,50 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi let(:entity4) { '8b9a2671-2abf-4bec-a682-22f6a8f7bf31' } let(:end_date) { Date.current } let(:sources) { Gitlab::Usage::Metrics::Aggregates::Sources } + let(:namespace) { described_class.to_s.deconstantize.constantize } let_it_be(:recorded_at) { Time.current.to_i } + def aggregated_metric(name:, time_frame:, source: "redis", events: %w[event1 event2 event3], operator: "OR", feature_flag: nil) + { + name: name, + source: source, + events: events, + operator: operator, + time_frame: time_frame, + feature_flag: feature_flag + }.compact.with_indifferent_access + end + context 'aggregated_metrics_data' do + shared_examples 'db sourced aggregated metrics without database_sourced_aggregated_metrics feature' do + before do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:aggregated_metrics).and_return(aggregated_metrics) + end + end + + context 'with disabled database_sourced_aggregated_metrics feature flag' do + before do + stub_feature_flags(database_sourced_aggregated_metrics: false) + end + + let(:aggregated_metrics) do + [ + aggregated_metric(name: "gmau_2", source: "database", time_frame: time_frame) + ] + end + + it 'skips database sourced metrics', :aggregate_failures do + results = {} + params = { start_date: start_date, end_date: end_date, recorded_at: recorded_at } + + expect(sources::PostgresHll).not_to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2 event3])) + expect(aggregated_metrics_data).to eq(results) + end + end + end + shared_examples 'aggregated_metrics_data' do context 'no aggregated metric is defined' do it 'returns empty hash' do @@ -31,37 +71,13 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi end end - context 'with disabled database_sourced_aggregated_metrics feature flag' do - before do - stub_feature_flags(database_sourced_aggregated_metrics: false) - end - - let(:aggregated_metrics) do - [ - { name: 'gmau_1', source: 'redis', events: %w[event3 event5], operator: "OR" }, - { name: 'gmau_2', source: 'database', events: %w[event1 event2 event3], operator: "OR" } - ].map(&:with_indifferent_access) - end - - it 'skips database sourced metrics', :aggregate_failures do - results = { - 'gmau_1' => 5 - } - - params = { start_date: start_date, end_date: end_date, recorded_at: recorded_at } - - expect(sources::RedisHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event3 event5])).and_return(5) - expect(sources::PostgresHll).not_to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2 event3])) - expect(aggregated_metrics_data).to eq(results) - end - end - context 'with AND operator' do let(:aggregated_metrics) do + params = { source: datasource, operator: "AND", time_frame: time_frame } [ - { name: 'gmau_1', source: 'redis', events: %w[event3 event5], operator: "AND" }, - { name: 'gmau_2', source: 'database', events: %w[event1 event2 event3], operator: "AND" } - ].map(&:with_indifferent_access) + aggregated_metric(**params.merge(name: "gmau_1", events: %w[event3 event5])), + aggregated_metric(**params.merge(name: "gmau_2")) + ] end it 'returns the number of unique events recorded for every metric in aggregate', :aggregate_failures do @@ -73,30 +89,30 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi # gmau_1 data is as follow # |A| => 4 - expect(sources::RedisHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event3')).and_return(4) + expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event3')).and_return(4) # |B| => 6 - expect(sources::RedisHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event5')).and_return(6) + expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event5')).and_return(6) # |A + B| => 8 - expect(sources::RedisHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event3 event5])).and_return(8) + expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event3 event5])).and_return(8) # Exclusion inclusion principle formula to calculate intersection of 2 sets # |A & B| = (|A| + |B|) - |A + B| => (4 + 6) - 8 => 2 # gmau_2 data is as follow: # |A| => 2 - expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event1')).and_return(2) + expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event1')).and_return(2) # |B| => 3 - expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event2')).and_return(3) + expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event2')).and_return(3) # |C| => 5 - expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event3')).and_return(5) + expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event3')).and_return(5) # |A + B| => 4 therefore |A & B| = (|A| + |B|) - |A + B| => 2 + 3 - 4 => 1 - expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2])).and_return(4) + expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2])).and_return(4) # |A + C| => 6 therefore |A & C| = (|A| + |C|) - |A + C| => 2 + 5 - 6 => 1 - expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event3])).and_return(6) + expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event3])).and_return(6) # |B + C| => 7 therefore |B & C| = (|B| + |C|) - |B + C| => 3 + 5 - 7 => 1 - expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event2 event3])).and_return(7) + expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event2 event3])).and_return(7) # |A + B + C| => 8 - expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2 event3])).and_return(8) + expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2 event3])).and_return(8) # Exclusion inclusion principle formula to calculate intersection of 3 sets # |A & B & C| = (|A & B| + |A & C| + |B & C|) - (|A| + |B| + |C|) + |A + B + C| # (1 + 1 + 1) - (2 + 3 + 5) + 8 => 1 @@ -108,20 +124,17 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi context 'with OR operator' do let(:aggregated_metrics) do [ - { name: 'gmau_1', source: 'redis', events: %w[event3 event5], operator: "OR" }, - { name: 'gmau_2', source: 'database', events: %w[event1 event2 event3], operator: "OR" } - ].map(&:with_indifferent_access) + aggregated_metric(name: "gmau_1", source: datasource, time_frame: time_frame, operator: "OR") + ] end it 'returns the number of unique events occurred for any metric in aggregate', :aggregate_failures do results = { - 'gmau_1' => 5, - 'gmau_2' => 3 + 'gmau_1' => 5 } params = { start_date: start_date, end_date: end_date, recorded_at: recorded_at } - expect(sources::RedisHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event3 event5])).and_return(5) - expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2 event3])).and_return(3) + expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2 event3])).and_return(5) expect(aggregated_metrics_data).to eq(results) end end @@ -130,21 +143,22 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi let(:enabled_feature_flag) { 'test_ff_enabled' } let(:disabled_feature_flag) { 'test_ff_disabled' } let(:aggregated_metrics) do + params = { source: datasource, time_frame: time_frame } [ # represents stable aggregated metrics that has been fully released - { name: 'gmau_without_ff', source: 'redis', events: %w[event3_slot event5_slot], operator: "OR" }, + aggregated_metric(**params.merge(name: "gmau_without_ff")), # represents new aggregated metric that is under performance testing on gitlab.com - { name: 'gmau_enabled', source: 'redis', events: %w[event4], operator: "OR", feature_flag: enabled_feature_flag }, + aggregated_metric(**params.merge(name: "gmau_enabled", feature_flag: enabled_feature_flag)), # represents aggregated metric that is under development and shouldn't be yet collected even on gitlab.com - { name: 'gmau_disabled', source: 'redis', events: %w[event4], operator: "OR", feature_flag: disabled_feature_flag } - ].map(&:with_indifferent_access) + aggregated_metric(**params.merge(name: "gmau_disabled", feature_flag: disabled_feature_flag)) + ] end it 'does not calculate data for aggregates with ff turned off' do skip_feature_flags_yaml_validation skip_default_enabled_yaml_check stub_feature_flags(enabled_feature_flag => true, disabled_feature_flag => false) - allow(sources::RedisHll).to receive(:calculate_metrics_union).and_return(6) + allow(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).and_return(6) expect(aggregated_metrics_data).to eq('gmau_without_ff' => 6, 'gmau_enabled' => 6) end @@ -156,31 +170,29 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi it 'raises error when unknown aggregation operator is used' do allow_next_instance_of(described_class) do |instance| allow(instance).to receive(:aggregated_metrics) - .and_return([{ name: 'gmau_1', source: 'redis', events: %w[event1_slot], operator: "SUM" }]) + .and_return([aggregated_metric(name: 'gmau_1', source: datasource, operator: "SUM", time_frame: time_frame)]) end - expect { aggregated_metrics_data }.to raise_error Gitlab::Usage::Metrics::Aggregates::UnknownAggregationOperator + expect { aggregated_metrics_data }.to raise_error namespace::UnknownAggregationOperator end it 'raises error when unknown aggregation source is used' do allow_next_instance_of(described_class) do |instance| allow(instance).to receive(:aggregated_metrics) - .and_return([{ name: 'gmau_1', source: 'whoami', events: %w[event1_slot], operator: "AND" }]) + .and_return([aggregated_metric(name: 'gmau_1', source: 'whoami', time_frame: time_frame)]) end - expect { aggregated_metrics_data }.to raise_error Gitlab::Usage::Metrics::Aggregates::UnknownAggregationSource + expect { aggregated_metrics_data }.to raise_error namespace::UnknownAggregationSource end - it 're raises Gitlab::UsageDataCounters::HLLRedisCounter::EventError' do - error = Gitlab::UsageDataCounters::HLLRedisCounter::EventError - allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:calculate_events_union).and_raise(error) - + it 'raises error when union is missing' do allow_next_instance_of(described_class) do |instance| allow(instance).to receive(:aggregated_metrics) - .and_return([{ name: 'gmau_1', source: 'redis', events: %w[event1_slot], operator: "OR" }]) + .and_return([aggregated_metric(name: 'gmau_1', source: datasource, time_frame: time_frame)]) end + allow(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).and_raise(sources::UnionNotAvailable) - expect { aggregated_metrics_data }.to raise_error error + expect { aggregated_metrics_data }.to raise_error sources::UnionNotAvailable end end @@ -192,7 +204,7 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi it 'rescues unknown aggregation operator error' do allow_next_instance_of(described_class) do |instance| allow(instance).to receive(:aggregated_metrics) - .and_return([{ name: 'gmau_1', source: 'redis', events: %w[event1_slot], operator: "SUM" }]) + .and_return([aggregated_metric(name: 'gmau_1', source: datasource, operator: "SUM", time_frame: time_frame)]) end expect(aggregated_metrics_data).to eq('gmau_1' => -1) @@ -201,20 +213,91 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi it 'rescues unknown aggregation source error' do allow_next_instance_of(described_class) do |instance| allow(instance).to receive(:aggregated_metrics) - .and_return([{ name: 'gmau_1', source: 'whoami', events: %w[event1_slot], operator: "AND" }]) + .and_return([aggregated_metric(name: 'gmau_1', source: 'whoami', time_frame: time_frame)]) end expect(aggregated_metrics_data).to eq('gmau_1' => -1) end - it 'rescues Gitlab::UsageDataCounters::HLLRedisCounter::EventError' do - error = Gitlab::UsageDataCounters::HLLRedisCounter::EventError - allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:calculate_events_union).and_raise(error) - + it 'rescues error when union is missing' do allow_next_instance_of(described_class) do |instance| allow(instance).to receive(:aggregated_metrics) - .and_return([{ name: 'gmau_1', source: 'redis', events: %w[event1_slot], operator: "OR" }]) + .and_return([aggregated_metric(name: 'gmau_1', source: datasource, time_frame: time_frame)]) end + allow(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).and_raise(sources::UnionNotAvailable) + + expect(aggregated_metrics_data).to eq('gmau_1' => -1) + end + end + end + end + + shared_examples 'database_sourced_aggregated_metrics' do + let(:datasource) { namespace::DATABASE_SOURCE } + + it_behaves_like 'aggregated_metrics_data' + end + + shared_examples 'redis_sourced_aggregated_metrics' do + let(:datasource) { namespace::REDIS_SOURCE } + + it_behaves_like 'aggregated_metrics_data' do + context 'error handling' do + let(:aggregated_metrics) { [aggregated_metric(name: 'gmau_1', source: datasource, time_frame: time_frame)] } + let(:error) { Gitlab::UsageDataCounters::HLLRedisCounter::EventError } + + before do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:aggregated_metrics).and_return(aggregated_metrics) + end + allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:calculate_events_union).and_raise(error) + end + + context 'development and test environment' do + it 're raises Gitlab::UsageDataCounters::HLLRedisCounter::EventError' do + expect { aggregated_metrics_data }.to raise_error error + end + end + + context 'production' do + it 'rescues Gitlab::UsageDataCounters::HLLRedisCounter::EventError' do + stub_rails_env('production') + + expect(aggregated_metrics_data).to eq('gmau_1' => -1) + end + end + end + end + end + + describe '.aggregated_metrics_all_time_data' do + subject(:aggregated_metrics_data) { described_class.new(recorded_at).all_time_data } + + let(:start_date) { nil } + let(:end_date) { nil } + let(:time_frame) { ['all'] } + + it_behaves_like 'database_sourced_aggregated_metrics' + it_behaves_like 'db sourced aggregated metrics without database_sourced_aggregated_metrics feature' + + context 'redis sourced aggregated metrics' do + let(:aggregated_metrics) { [aggregated_metric(name: 'gmau_1', time_frame: time_frame)] } + + before do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:aggregated_metrics).and_return(aggregated_metrics) + end + end + + context 'development and test environment' do + it 'raises Gitlab::Usage::Metrics::Aggregates::DisallowedAggregationTimeFrame' do + expect { aggregated_metrics_data }.to raise_error namespace::DisallowedAggregationTimeFrame + end + end + + context 'production env' do + it 'returns fallback value for unsupported time frame' do + stub_rails_env('production') expect(aggregated_metrics_data).to eq('gmau_1' => -1) end @@ -223,7 +306,7 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi end it 'allows for YAML aliases in aggregated metrics configs' do - expect(YAML).to receive(:safe_load).with(kind_of(String), aliases: true) + expect(YAML).to receive(:safe_load).with(kind_of(String), aliases: true).at_least(:once) described_class.new(recorded_at) end @@ -232,32 +315,34 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi subject(:aggregated_metrics_data) { described_class.new(recorded_at).weekly_data } let(:start_date) { 7.days.ago.to_date } + let(:time_frame) { ['7d'] } - it_behaves_like 'aggregated_metrics_data' + it_behaves_like 'database_sourced_aggregated_metrics' + it_behaves_like 'redis_sourced_aggregated_metrics' + it_behaves_like 'db sourced aggregated metrics without database_sourced_aggregated_metrics feature' end describe '.aggregated_metrics_monthly_data' do subject(:aggregated_metrics_data) { described_class.new(recorded_at).monthly_data } let(:start_date) { 4.weeks.ago.to_date } + let(:time_frame) { ['28d'] } - it_behaves_like 'aggregated_metrics_data' + it_behaves_like 'database_sourced_aggregated_metrics' + it_behaves_like 'redis_sourced_aggregated_metrics' + it_behaves_like 'db sourced aggregated metrics without database_sourced_aggregated_metrics feature' context 'metrics union calls' do - let(:aggregated_metrics) do - [ - { name: 'gmau_3', source: 'redis', events: %w[event1_slot event2_slot event3_slot event5_slot], operator: "AND" } - ].map(&:with_indifferent_access) - end - it 'caches intermediate operations', :aggregate_failures do + events = %w[event1 event2 event3 event5] allow_next_instance_of(described_class) do |instance| - allow(instance).to receive(:aggregated_metrics).and_return(aggregated_metrics) + allow(instance).to receive(:aggregated_metrics) + .and_return([aggregated_metric(name: 'gmau_1', events: events, operator: "AND", time_frame: time_frame)]) end params = { start_date: start_date, end_date: end_date, recorded_at: recorded_at } - aggregated_metrics[0][:events].each do |event| + events.each do |event| expect(sources::RedisHll).to receive(:calculate_metrics_union) .with(params.merge(metric_names: event)) .once @@ -265,7 +350,7 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi end 2.upto(4) do |subset_size| - aggregated_metrics[0][:events].combination(subset_size).each do |events| + events.combination(subset_size).each do |events| expect(sources::RedisHll).to receive(:calculate_metrics_union) .with(params.merge(metric_names: events)) .once diff --git a/spec/lib/gitlab/usage/metrics/aggregates/sources/postgres_hll_spec.rb b/spec/lib/gitlab/usage/metrics/aggregates/sources/postgres_hll_spec.rb index 7b8be8e8bc6..a2a40f17269 100644 --- a/spec/lib/gitlab/usage/metrics/aggregates/sources/postgres_hll_spec.rb +++ b/spec/lib/gitlab/usage/metrics/aggregates/sources/postgres_hll_spec.rb @@ -69,7 +69,7 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Sources::PostgresHll, :clean_ it 'persists serialized data in Redis' do Gitlab::Redis::SharedState.with do |redis| - expect(redis).to receive(:set).with("#{metric_1}_weekly-#{recorded_at.to_i}", '{"141":1,"56":1}', ex: 120.hours) + expect(redis).to receive(:set).with("#{metric_1}_7d-#{recorded_at.to_i}", '{"141":1,"56":1}', ex: 120.hours) end save_aggregated_metrics @@ -81,7 +81,7 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Sources::PostgresHll, :clean_ it 'persists serialized data in Redis' do Gitlab::Redis::SharedState.with do |redis| - expect(redis).to receive(:set).with("#{metric_1}_monthly-#{recorded_at.to_i}", '{"141":1,"56":1}', ex: 120.hours) + expect(redis).to receive(:set).with("#{metric_1}_28d-#{recorded_at.to_i}", '{"141":1,"56":1}', ex: 120.hours) end save_aggregated_metrics @@ -93,7 +93,7 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Sources::PostgresHll, :clean_ it 'persists serialized data in Redis' do Gitlab::Redis::SharedState.with do |redis| - expect(redis).to receive(:set).with("#{metric_1}_all_time-#{recorded_at.to_i}", '{"141":1,"56":1}', ex: 120.hours) + expect(redis).to receive(:set).with("#{metric_1}_all-#{recorded_at.to_i}", '{"141":1,"56":1}', ex: 120.hours) end save_aggregated_metrics diff --git a/spec/lib/gitlab/usage/metrics/names_suggestions/generator_spec.rb b/spec/lib/gitlab/usage/metrics/names_suggestions/generator_spec.rb new file mode 100644 index 00000000000..cd0413feab4 --- /dev/null +++ b/spec/lib/gitlab/usage/metrics/names_suggestions/generator_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Usage::Metrics::NamesSuggestions::Generator do + include UsageDataHelpers + + before do + stub_usage_data_connections + end + + describe '#generate' do + shared_examples 'name suggestion' do + it 'return correct name' do + expect(described_class.generate(key_path)).to eq name_suggestion + end + end + + context 'for count with default column metrics' do + it_behaves_like 'name suggestion' do + # corresponding metric is collected with count(Board) + let(:key_path) { 'counts.boards' } + let(:name_suggestion) { 'count_boards' } + end + end + + context 'for count distinct with column defined metrics' do + it_behaves_like 'name suggestion' do + # corresponding metric is collected with distinct_count(ZoomMeeting, :issue_id) + let(:key_path) { 'counts.issues_using_zoom_quick_actions' } + let(:name_suggestion) { 'count_distinct_issue_id_from_zoom_meetings' } + end + end + + context 'for sum metrics' do + it_behaves_like 'name suggestion' do + # corresponding metric is collected with sum(JiraImportState.finished, :imported_issues_count) + let(:key_path) { 'counts.jira_imports_total_imported_issues_count' } + let(:name_suggestion) { "sum_imported_issues_count_from_<adjective describing: '(jira_imports.status = 4)'>_jira_imports" } + end + end + + context 'for add metrics' do + it_behaves_like 'name suggestion' do + # corresponding metric is collected with add(data[:personal_snippets], data[:project_snippets]) + let(:key_path) { 'counts.snippets' } + let(:name_suggestion) { "add_count_<adjective describing: '(snippets.type = 'PersonalSnippet')'>_snippets_and_count_<adjective describing: '(snippets.type = 'ProjectSnippet')'>_snippets" } + end + end + + context 'for redis metrics' do + it_behaves_like 'name suggestion' do + # corresponding metric is collected with redis_usage_data { unique_visit_service.unique_visits_for(targets: :analytics) } + let(:key_path) { 'analytics_unique_visits.analytics_unique_visits_for_any_target' } + let(:name_suggestion) { '<please fill metric name>' } + end + end + + context 'for alt_usage_data metrics' do + it_behaves_like 'name suggestion' do + # corresponding metric is collected with alt_usage_data(fallback: nil) { operating_system } + let(:key_path) { 'settings.operating_system' } + let(:name_suggestion) { '<please fill metric name>' } + end + end + end +end diff --git a/spec/lib/gitlab/usage/metrics/names_suggestions/relation_parsers/constraints_spec.rb b/spec/lib/gitlab/usage/metrics/names_suggestions/relation_parsers/constraints_spec.rb new file mode 100644 index 00000000000..68016e760e4 --- /dev/null +++ b/spec/lib/gitlab/usage/metrics/names_suggestions/relation_parsers/constraints_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Usage::Metrics::NamesSuggestions::RelationParsers::Constraints do + describe '#accept' do + let(:collector) { Arel::Collectors::SubstituteBinds.new(ActiveRecord::Base.connection, Arel::Collectors::SQLString.new) } + + it 'builds correct constraints description' do + table = Arel::Table.new('records') + arel = table.from.project(table['id'].count).where(table[:attribute].eq(true).and(table[:some_value].gt(5))) + described_class.new(ApplicationRecord.connection).accept(arel, collector) + + expect(collector.value).to eql '(records.attribute = true AND records.some_value > 5)' + end + end +end diff --git a/spec/lib/gitlab/usage_data_counters/aggregated_metrics_spec.rb b/spec/lib/gitlab/usage_data_counters/aggregated_metrics_spec.rb index 58f974fbe12..9aba86cdaf2 100644 --- a/spec/lib/gitlab/usage_data_counters/aggregated_metrics_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/aggregated_metrics_spec.rb @@ -23,6 +23,22 @@ RSpec.describe 'aggregated metrics' do end end + RSpec::Matchers.define :have_known_time_frame do + allowed_time_frames = [ + Gitlab::Utils::UsageData::ALL_TIME_TIME_FRAME_NAME, + Gitlab::Utils::UsageData::TWENTY_EIGHT_DAYS_TIME_FRAME_NAME, + Gitlab::Utils::UsageData::SEVEN_DAYS_TIME_FRAME_NAME + ] + + match do |aggregate| + (aggregate[:time_frame] - allowed_time_frames).empty? + end + + failure_message do |aggregate| + "Aggregate with name: `#{aggregate[:name]}` uses not allowed time_frame`#{aggregate[:time_frame] - allowed_time_frames}`" + end + end + let_it_be(:known_events) do Gitlab::UsageDataCounters::HLLRedisCounter.known_events end @@ -38,10 +54,18 @@ RSpec.describe 'aggregated metrics' do expect(aggregated_metrics).to all has_known_source end + it 'all aggregated metrics has known source' do + expect(aggregated_metrics).to all have_known_time_frame + end + aggregated_metrics&.select { |agg| agg[:source] == Gitlab::Usage::Metrics::Aggregates::REDIS_SOURCE }&.each do |aggregate| context "for #{aggregate[:name]} aggregate of #{aggregate[:events].join(' ')}" do let_it_be(:events_records) { known_events.select { |event| aggregate[:events].include?(event[:name]) } } + it "does not include 'all' time frame for Redis sourced aggregate" do + expect(aggregate[:time_frame]).not_to include(Gitlab::Utils::UsageData::ALL_TIME_TIME_FRAME_NAME) + end + it "only refers to known events" do expect(aggregate[:events]).to all be_known_event end diff --git a/spec/lib/gitlab/usage_data_counters/code_review_events_spec.rb b/spec/lib/gitlab/usage_data_counters/code_review_events_spec.rb new file mode 100644 index 00000000000..664e7938a7e --- /dev/null +++ b/spec/lib/gitlab/usage_data_counters/code_review_events_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# If this spec fails, we need to add the new code review event to the correct aggregated metric +RSpec.describe 'Code review events' do + it 'the aggregated metrics contain all the code review metrics' do + path = Rails.root.join('lib/gitlab/usage_data_counters/aggregated_metrics/code_review.yml') + aggregated_events = YAML.safe_load(File.read(path), aliases: true)&.map(&:with_indifferent_access) + + code_review_aggregated_events = aggregated_events + .map { |event| event['events'] } + .flatten + .uniq + + code_review_events = Gitlab::UsageDataCounters::HLLRedisCounter.events_for_category("code_review") + + exceptions = %w[i_code_review_mr_diffs i_code_review_mr_single_file_diffs] + code_review_aggregated_events += exceptions + + expect(code_review_events - code_review_aggregated_events).to be_empty + end +end diff --git a/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb index b4894ec049f..d12dcdae955 100644 --- a/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb @@ -42,7 +42,8 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s 'terraform', 'ci_templates', 'quickactions', - 'pipeline_authoring' + 'pipeline_authoring', + 'epics_usage' ) end end @@ -150,10 +151,17 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s expect { described_class.track_event(different_aggregation, values: entity1, time: Date.current) }.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownAggregation) end - it 'raise error if metrics of unknown aggregation' do + it 'raise error if metrics of unknown event' do expect { described_class.track_event('unknown', values: entity1, time: Date.current) }.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownEvent) end + it 'reports an error if Feature.enabled raise an error' do + expect(Feature).to receive(:enabled?).and_raise(StandardError.new) + expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) + + described_class.track_event(:g_analytics_contribution, values: entity1, time: Date.current) + end + context 'for weekly events' do it 'sets the keys in Redis to expire automatically after the given expiry time' do described_class.track_event("g_analytics_contribution", values: entity1) diff --git a/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb index bf43f7552e6..f8f6494b92e 100644 --- a/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git let(:time) { Time.zone.now } context 'for Issue title edit actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_TITLE_CHANGED } def track_action(params) @@ -19,7 +19,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue description edit actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_DESCRIPTION_CHANGED } def track_action(params) @@ -29,7 +29,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue assignee edit actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_ASSIGNEE_CHANGED } def track_action(params) @@ -39,7 +39,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue make confidential actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_MADE_CONFIDENTIAL } def track_action(params) @@ -49,7 +49,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue make visible actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_MADE_VISIBLE } def track_action(params) @@ -59,7 +59,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue created actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_CREATED } def track_action(params) @@ -69,7 +69,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue closed actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_CLOSED } def track_action(params) @@ -79,7 +79,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue reopened actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_REOPENED } def track_action(params) @@ -89,7 +89,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue label changed actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_LABEL_CHANGED } def track_action(params) @@ -99,7 +99,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue cross-referenced actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_CROSS_REFERENCED } def track_action(params) @@ -109,7 +109,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue moved actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_MOVED } def track_action(params) @@ -119,7 +119,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue cloned actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_CLONED } def track_action(params) @@ -129,7 +129,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue relate actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_RELATED } def track_action(params) @@ -139,7 +139,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue unrelate actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_UNRELATED } def track_action(params) @@ -149,7 +149,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue marked as duplicate actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_MARKED_AS_DUPLICATE } def track_action(params) @@ -159,7 +159,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue locked actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_LOCKED } def track_action(params) @@ -169,7 +169,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue unlocked actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_UNLOCKED } def track_action(params) @@ -179,7 +179,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue designs added actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_DESIGNS_ADDED } def track_action(params) @@ -189,7 +189,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue designs modified actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_DESIGNS_MODIFIED } def track_action(params) @@ -199,7 +199,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue designs removed actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_DESIGNS_REMOVED } def track_action(params) @@ -209,7 +209,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue due date changed actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_DUE_DATE_CHANGED } def track_action(params) @@ -219,7 +219,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue time estimate changed actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_TIME_ESTIMATE_CHANGED } def track_action(params) @@ -229,7 +229,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue time spent changed actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_TIME_SPENT_CHANGED } def track_action(params) @@ -239,7 +239,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue comment added actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_COMMENT_ADDED } def track_action(params) @@ -249,7 +249,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue comment edited actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_COMMENT_EDITED } def track_action(params) @@ -259,7 +259,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git end context 'for Issue comment removed actions' do - it_behaves_like 'a tracked issue edit event' do + it_behaves_like 'a daily tracked issuable event' do let(:action) { described_class::ISSUE_COMMENT_REMOVED } def track_action(params) diff --git a/spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb index a604de4a61f..6486a5a22ba 100644 --- a/spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb @@ -21,6 +21,14 @@ RSpec.describe Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter, :cl end end + shared_examples_for 'not tracked merge request unique event' do + specify do + expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event) + + subject + end + end + describe '.track_mr_diffs_action' do subject { described_class.track_mr_diffs_action(merge_request: merge_request) } @@ -284,4 +292,98 @@ RSpec.describe Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter, :cl let(:action) { described_class::MR_CREATE_FROM_ISSUE_ACTION } end end + + describe '.track_discussion_locked_action' do + subject { described_class.track_discussion_locked_action(user: user) } + + it_behaves_like 'a tracked merge request unique event' do + let(:action) { described_class::MR_DISCUSSION_LOCKED_ACTION } + end + end + + describe '.track_discussion_unlocked_action' do + subject { described_class.track_discussion_unlocked_action(user: user) } + + it_behaves_like 'a tracked merge request unique event' do + let(:action) { described_class::MR_DISCUSSION_UNLOCKED_ACTION } + end + end + + describe '.track_time_estimate_changed_action' do + subject { described_class.track_time_estimate_changed_action(user: user) } + + it_behaves_like 'a tracked merge request unique event' do + let(:action) { described_class::MR_TIME_ESTIMATE_CHANGED_ACTION } + end + end + + describe '.track_time_spent_changed_action' do + subject { described_class.track_time_spent_changed_action(user: user) } + + it_behaves_like 'a tracked merge request unique event' do + let(:action) { described_class::MR_TIME_SPENT_CHANGED_ACTION } + end + end + + describe '.track_assignees_changed_action' do + subject { described_class.track_assignees_changed_action(user: user) } + + it_behaves_like 'a tracked merge request unique event' do + let(:action) { described_class::MR_ASSIGNEES_CHANGED_ACTION } + end + end + + describe '.track_reviewers_changed_action' do + subject { described_class.track_reviewers_changed_action(user: user) } + + it_behaves_like 'a tracked merge request unique event' do + let(:action) { described_class::MR_REVIEWERS_CHANGED_ACTION } + end + end + + describe '.track_mr_including_ci_config' do + subject { described_class.track_mr_including_ci_config(user: user, merge_request: merge_request) } + + context 'when merge request includes a ci config change' do + before do + allow(merge_request).to receive(:diff_stats).and_return([double(path: 'abc.txt'), double(path: '.gitlab-ci.yml')]) + end + + it_behaves_like 'a tracked merge request unique event' do + let(:action) { described_class::MR_INCLUDING_CI_CONFIG_ACTION } + end + + context 'when FF usage_data_o_pipeline_authoring_unique_users_pushing_mr_ciconfigfile is disabled' do + before do + stub_feature_flags(usage_data_o_pipeline_authoring_unique_users_pushing_mr_ciconfigfile: false) + end + + it_behaves_like 'not tracked merge request unique event' + end + end + + context 'when merge request does not include any ci config change' do + before do + allow(merge_request).to receive(:diff_stats).and_return([double(path: 'abc.txt'), double(path: 'abc.xyz')]) + end + + it_behaves_like 'not tracked merge request unique event' + end + end + + describe '.track_milestone_changed_action' do + subject { described_class.track_milestone_changed_action(user: user) } + + it_behaves_like 'a tracked merge request unique event' do + let(:action) { described_class::MR_MILESTONE_CHANGED_ACTION } + end + end + + describe '.track_labels_changed_action' do + subject { described_class.track_labels_changed_action(user: user) } + + it_behaves_like 'a tracked merge request unique event' do + let(:action) { described_class::MR_LABELS_CHANGED_ACTION } + end + end end diff --git a/spec/lib/gitlab/usage_data_counters/package_event_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/package_event_counter_spec.rb index 7b5efb11034..1be2a83f98f 100644 --- a/spec/lib/gitlab/usage_data_counters/package_event_counter_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/package_event_counter_spec.rb @@ -14,7 +14,7 @@ RSpec.describe Gitlab::UsageDataCounters::PackageEventCounter, :clean_gitlab_red end it 'includes the right events' do - expect(described_class::KNOWN_EVENTS.size).to eq 45 + expect(described_class::KNOWN_EVENTS.size).to eq 48 end described_class::KNOWN_EVENTS.each do |event| diff --git a/spec/lib/gitlab/usage_data_counters/quick_action_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/quick_action_activity_unique_counter_spec.rb index d4c423f57fe..2df0f331f73 100644 --- a/spec/lib/gitlab/usage_data_counters/quick_action_activity_unique_counter_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/quick_action_activity_unique_counter_spec.rb @@ -160,4 +160,24 @@ RSpec.describe Gitlab::UsageDataCounters::QuickActionActivityUniqueCounter, :cle end end end + + context 'tracking invite_email' do + let(:quickaction_name) { 'invite_email' } + + context 'single email' do + let(:args) { 'someone@gitlab.com' } + + it_behaves_like 'a tracked quick action unique event' do + let(:action) { 'i_quickactions_invite_email_single' } + end + end + + context 'multiple emails' do + let(:args) { 'someone@gitlab.com another@gitlab.com' } + + it_behaves_like 'a tracked quick action unique event' do + let(:action) { 'i_quickactions_invite_email_multiple' } + end + end + end end diff --git a/spec/lib/gitlab/usage_data_queries_spec.rb b/spec/lib/gitlab/usage_data_queries_spec.rb index 7fc77593265..12eac643383 100644 --- a/spec/lib/gitlab/usage_data_queries_spec.rb +++ b/spec/lib/gitlab/usage_data_queries_spec.rb @@ -38,4 +38,12 @@ RSpec.describe Gitlab::UsageDataQueries do expect(described_class.sum(Issue, :weight)).to eq('SELECT SUM("issues"."weight") FROM "issues"') end end + + describe '.add' do + it 'returns the combined raw SQL with an inner query' do + expect(described_class.add('SELECT COUNT("users"."id") FROM "users"', + 'SELECT COUNT("issues"."id") FROM "issues"')) + .to eq('SELECT (SELECT COUNT("users"."id") FROM "users") + (SELECT COUNT("issues"."id") FROM "issues")') + end + end end diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index 602f6640d72..b1581bf02a6 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -382,14 +382,15 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do describe 'usage_activity_by_stage_monitor' do it 'includes accurate usage_activity_by_stage data' do for_defined_days_back do - user = create(:user, dashboard: 'operations') + user = create(:user, dashboard: 'operations') cluster = create(:cluster, user: user) - create(:project, creator: user) + project = create(:project, creator: user) create(:clusters_applications_prometheus, :installed, cluster: cluster) create(:project_tracing_setting) create(:project_error_tracking_setting) create(:incident) create(:incident, alert_management_alert: create(:alert_management_alert)) + create(:alert_management_http_integration, :active, project: project) end expect(described_class.usage_activity_by_stage_monitor({})).to include( @@ -399,10 +400,12 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do projects_with_tracing_enabled: 2, projects_with_error_tracking_enabled: 2, projects_with_incidents: 4, - projects_with_alert_incidents: 2 + projects_with_alert_incidents: 2, + projects_with_enabled_alert_integrations_histogram: { '1' => 2 } ) - expect(described_class.usage_activity_by_stage_monitor(described_class.last_28_days_time_period)).to include( + data_28_days = described_class.usage_activity_by_stage_monitor(described_class.last_28_days_time_period) + expect(data_28_days).to include( clusters: 1, clusters_applications_prometheus: 1, operations_dashboard_default_dashboard: 1, @@ -411,6 +414,8 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do projects_with_incidents: 2, projects_with_alert_incidents: 1 ) + + expect(data_28_days).not_to include(:projects_with_enabled_alert_integrations_histogram) end end @@ -528,14 +533,14 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do expect(subject.keys).to include(*UsageDataHelpers::USAGE_DATA_KEYS) end - it 'gathers usage counts' do + it 'gathers usage counts', :aggregate_failures do count_data = subject[:counts] expect(count_data[:boards]).to eq(1) expect(count_data[:projects]).to eq(4) - expect(count_data.values_at(*UsageDataHelpers::SMAU_KEYS)).to all(be_an(Integer)) expect(count_data.keys).to include(*UsageDataHelpers::COUNTS_KEYS) expect(UsageDataHelpers::COUNTS_KEYS - count_data.keys).to be_empty + expect(count_data.values).to all(be_a_kind_of(Integer)) end it 'gathers usage counts correctly' do @@ -1129,12 +1134,40 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do end end + describe ".operating_system" do + let(:ohai_data) { { "platform" => "ubuntu", "platform_version" => "20.04" } } + + before do + allow_next_instance_of(Ohai::System) do |ohai| + allow(ohai).to receive(:data).and_return(ohai_data) + end + end + + subject { described_class.operating_system } + + it { is_expected.to eq("ubuntu-20.04") } + + context 'when on Debian with armv architecture' do + let(:ohai_data) { { "platform" => "debian", "platform_version" => "10", 'kernel' => { 'machine' => 'armv' } } } + + it { is_expected.to eq("raspbian-10") } + end + end + describe ".system_usage_data_settings" do + before do + allow(described_class).to receive(:operating_system).and_return('ubuntu-20.04') + end + subject { described_class.system_usage_data_settings } it 'gathers settings usage data', :aggregate_failures do expect(subject[:settings][:ldap_encrypted_secrets_enabled]).to eq(Gitlab::Auth::Ldap::Config.encrypted_secrets.active?) end + + it 'populates operating system information' do + expect(subject[:settings][:operating_system]).to eq('ubuntu-20.04') + end end end @@ -1325,7 +1358,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do let(:categories) { ::Gitlab::UsageDataCounters::HLLRedisCounter.categories } let(:ineligible_total_categories) do - %w[source_code ci_secrets_management incident_management_alerts snippets terraform pipeline_authoring] + %w[source_code ci_secrets_management incident_management_alerts snippets terraform epics_usage] end it 'has all known_events' do @@ -1347,25 +1380,20 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do end end - describe '.aggregated_metrics_weekly' do - subject(:aggregated_metrics_payload) { described_class.aggregated_metrics_weekly } + describe '.aggregated_metrics_data' do + it 'uses ::Gitlab::Usage::Metrics::Aggregates::Aggregate methods', :aggregate_failures do + expected_payload = { + counts_weekly: { aggregated_metrics: { global_search_gmau: 123 } }, + counts_monthly: { aggregated_metrics: { global_search_gmau: 456 } }, + counts: { aggregate_global_search_gmau: 789 } + } - it 'uses ::Gitlab::Usage::Metrics::Aggregates::Aggregate#weekly_data', :aggregate_failures do expect_next_instance_of(::Gitlab::Usage::Metrics::Aggregates::Aggregate) do |instance| expect(instance).to receive(:weekly_data).and_return(global_search_gmau: 123) + expect(instance).to receive(:monthly_data).and_return(global_search_gmau: 456) + expect(instance).to receive(:all_time_data).and_return(global_search_gmau: 789) end - expect(aggregated_metrics_payload).to eq(aggregated_metrics: { global_search_gmau: 123 }) - end - end - - describe '.aggregated_metrics_monthly' do - subject(:aggregated_metrics_payload) { described_class.aggregated_metrics_monthly } - - it 'uses ::Gitlab::Usage::Metrics::Aggregates::Aggregate#monthly_data', :aggregate_failures do - expect_next_instance_of(::Gitlab::Usage::Metrics::Aggregates::Aggregate) do |instance| - expect(instance).to receive(:monthly_data).and_return(global_search_gmau: 123) - end - expect(aggregated_metrics_payload).to eq(aggregated_metrics: { global_search_gmau: 123 }) + expect(described_class.aggregated_metrics_data).to eq(expected_payload) end end diff --git a/spec/lib/gitlab/utils/usage_data_spec.rb b/spec/lib/gitlab/utils/usage_data_spec.rb index e964e695828..6e1904c43e1 100644 --- a/spec/lib/gitlab/utils/usage_data_spec.rb +++ b/spec/lib/gitlab/utils/usage_data_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe Gitlab::Utils::UsageData do + include Database::DatabaseHelpers + describe '#count' do let(:relation) { double(:relation) } @@ -183,6 +185,120 @@ RSpec.describe Gitlab::Utils::UsageData do end end + describe '#histogram' do + let_it_be(:projects) { create_list(:project, 3) } + let(:project1) { projects.first } + let(:project2) { projects.second } + let(:project3) { projects.third } + + let(:fallback) { described_class::HISTOGRAM_FALLBACK } + let(:relation) { AlertManagement::HttpIntegration.active } + let(:column) { :project_id } + + def expect_error(exception, message, &block) + expect(Gitlab::ErrorTracking) + .to receive(:track_and_raise_for_dev_exception) + .with(instance_of(exception)) + .and_call_original + + expect(&block).to raise_error( + an_instance_of(exception).and( + having_attributes(message: message, backtrace: be_kind_of(Array))) + ) + end + + it 'checks bucket bounds to be not equal' do + expect_error(ArgumentError, 'Lower bucket bound cannot equal to upper bucket bound') do + described_class.histogram(relation, column, buckets: 1..1) + end + end + + it 'checks bucket_size being non-zero' do + expect_error(ArgumentError, 'Bucket size cannot be zero') do + described_class.histogram(relation, column, buckets: 1..2, bucket_size: 0) + end + end + + it 'limits the amount of buckets without providing bucket_size argument' do + expect_error(ArgumentError, 'Bucket size 101 exceeds the limit of 100') do + described_class.histogram(relation, column, buckets: 1..101) + end + end + + it 'limits the amount of buckets when providing bucket_size argument' do + expect_error(ArgumentError, 'Bucket size 101 exceeds the limit of 100') do + described_class.histogram(relation, column, buckets: 1..2, bucket_size: 101) + end + end + + it 'without data' do + histogram = described_class.histogram(relation, column, buckets: 1..100) + + expect(histogram).to eq({}) + end + + it 'aggregates properly within bounds' do + create(:alert_management_http_integration, :active, project: project1) + create(:alert_management_http_integration, :inactive, project: project1) + + create(:alert_management_http_integration, :active, project: project2) + create(:alert_management_http_integration, :active, project: project2) + create(:alert_management_http_integration, :inactive, project: project2) + + create(:alert_management_http_integration, :active, project: project3) + create(:alert_management_http_integration, :inactive, project: project3) + + histogram = described_class.histogram(relation, column, buckets: 1..100) + + expect(histogram).to eq('1' => 2, '2' => 1) + end + + it 'aggregates properly out of bounds' do + create_list(:alert_management_http_integration, 3, :active, project: project1) + histogram = described_class.histogram(relation, column, buckets: 1..2) + + expect(histogram).to eq('2' => 1) + end + + it 'returns fallback and logs canceled queries' do + create(:alert_management_http_integration, :active, project: project1) + + expect(Gitlab::AppJsonLogger).to receive(:error).with( + event: 'histogram', + relation: relation.table_name, + operation: 'histogram', + operation_args: [column, 1, 100, 99], + query: kind_of(String), + message: /PG::QueryCanceled/ + ) + + with_statement_timeout(0.001) do + relation = AlertManagement::HttpIntegration.select('pg_sleep(0.002)') + histogram = described_class.histogram(relation, column, buckets: 1..100) + + expect(histogram).to eq(fallback) + end + end + end + + describe '#add' do + it 'adds given values' do + expect(described_class.add(1, 3)).to eq(4) + end + + it 'adds given values' do + expect(described_class.add).to eq(0) + end + + it 'returns the fallback value when adding fails' do + expect(described_class.add(nil, 3)).to eq(-1) + end + + it 'returns the fallback value one of the arguments is negative' do + expect(described_class.add(-1, 1)).to eq(-1) + end + end + describe '#alt_usage_data' do it 'returns the fallback when it gets an error' do expect(described_class.alt_usage_data { raise StandardError } ).to eq(-1) @@ -203,6 +319,12 @@ RSpec.describe Gitlab::Utils::UsageData do expect(described_class.redis_usage_data { raise ::Redis::CommandError } ).to eq(-1) end + it 'returns the fallback when Redis HLL raises any error' do + stub_const("Gitlab::Utils::UsageData::FALLBACK", 15) + + expect(described_class.redis_usage_data { raise Gitlab::UsageDataCounters::HLLRedisCounter::CategoryMismatch } ).to eq(15) + end + it 'returns the evaluated block when given' do expect(described_class.redis_usage_data { 1 }).to eq(1) end @@ -222,6 +344,13 @@ RSpec.describe Gitlab::Utils::UsageData do end describe '#with_prometheus_client' do + it 'returns fallback with for an exception in yield block' do + allow(described_class).to receive(:prometheus_client).and_return(Gitlab::PrometheusClient.new('http://localhost:9090')) + result = described_class.with_prometheus_client(fallback: -42) { |client| raise StandardError } + + expect(result).to be(-42) + end + shared_examples 'query data from Prometheus' do it 'yields a client instance and returns the block result' do result = described_class.with_prometheus_client { |client| client } @@ -231,10 +360,10 @@ RSpec.describe Gitlab::Utils::UsageData do end shared_examples 'does not query data from Prometheus' do - it 'returns nil by default' do + it 'returns {} by default' do result = described_class.with_prometheus_client { |client| client } - expect(result).to be_nil + expect(result).to eq({}) end it 'returns fallback if provided' do @@ -338,38 +467,15 @@ RSpec.describe Gitlab::Utils::UsageData do let(:value) { '9f302fea-f828-4ca9-aef4-e10bd723c0b3' } let(:event_name) { 'incident_management_alert_status_changed' } let(:unknown_event) { 'unknown' } - let(:feature) { "usage_data_#{event_name}" } - - before do - skip_feature_flags_yaml_validation - end - context 'with feature enabled' do - before do - stub_feature_flags(feature => true) - end + it 'tracks redis hll event' do + expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(event_name, values: value) - it 'tracks redis hll event' do - expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(event_name, values: value) - - described_class.track_usage_event(event_name, value) - end - - it 'raise an error for unknown event' do - expect { described_class.track_usage_event(unknown_event, value) }.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownEvent) - end + described_class.track_usage_event(event_name, value) end - context 'with feature disabled' do - before do - stub_feature_flags(feature => false) - end - - it 'does not track event' do - expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event) - - described_class.track_usage_event(event_name, value) - end + it 'raise an error for unknown event' do + expect { described_class.track_usage_event(unknown_event, value) }.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownEvent) end end end diff --git a/spec/lib/gitlab/visibility_level_spec.rb b/spec/lib/gitlab/visibility_level_spec.rb index 63c31c82d59..0d34d22cbbe 100644 --- a/spec/lib/gitlab/visibility_level_spec.rb +++ b/spec/lib/gitlab/visibility_level_spec.rb @@ -131,4 +131,29 @@ RSpec.describe Gitlab::VisibilityLevel do end end end + + describe '.options' do + context 'keys' do + it 'returns the allowed visibility levels' do + expect(described_class.options.keys).to contain_exactly('Private', 'Internal', 'Public') + end + end + end + + describe '.level_name' do + using RSpec::Parameterized::TableSyntax + + where(:level_value, :level_name) do + described_class::PRIVATE | 'Private' + described_class::INTERNAL | 'Internal' + described_class::PUBLIC | 'Public' + non_existing_record_access_level | 'Unknown' + end + + with_them do + it 'returns the name of the visibility level' do + expect(described_class.level_name(level_value)).to eq(level_name) + end + end + end end diff --git a/spec/lib/gitlab/word_diff/chunk_collection_spec.rb b/spec/lib/gitlab/word_diff/chunk_collection_spec.rb new file mode 100644 index 00000000000..aa837f760c1 --- /dev/null +++ b/spec/lib/gitlab/word_diff/chunk_collection_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::WordDiff::ChunkCollection do + subject(:collection) { described_class.new } + + describe '#add' do + it 'adds elements to the chunk collection' do + collection.add('Hello') + collection.add(' World') + + expect(collection.content).to eq('Hello World') + end + end + + describe '#content' do + subject { collection.content } + + context 'when no elements in the collection' do + it { is_expected.to eq('') } + end + + context 'when elements exist' do + before do + collection.add('Hi') + collection.add(' GitLab!') + end + + it { is_expected.to eq('Hi GitLab!') } + end + end + + describe '#reset' do + it 'clears the collection' do + collection.add('1') + collection.add('2') + + collection.reset + + expect(collection.content).to eq('') + end + end +end diff --git a/spec/lib/gitlab/word_diff/line_processor_spec.rb b/spec/lib/gitlab/word_diff/line_processor_spec.rb new file mode 100644 index 00000000000..f448f5b5eb6 --- /dev/null +++ b/spec/lib/gitlab/word_diff/line_processor_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::WordDiff::LineProcessor do + subject(:line_processor) { described_class.new(line) } + + describe '#extract' do + subject(:segment) { line_processor.extract } + + context 'when line is a diff hunk' do + let(:line) { "@@ -1,14 +1,13 @@\n" } + + it 'returns DiffHunk segment' do + expect(segment).to be_a(Gitlab::WordDiff::Segments::DiffHunk) + expect(segment.to_s).to eq('@@ -1,14 +1,13 @@') + end + end + + context 'when line has a newline delimiter' do + let(:line) { "~\n" } + + it 'returns Newline segment' do + expect(segment).to be_a(Gitlab::WordDiff::Segments::Newline) + expect(segment.to_s).to eq('') + end + end + + context 'when line has only space' do + let(:line) { " \n" } + + it 'returns nil' do + is_expected.to be_nil + end + end + + context 'when line has content' do + let(:line) { "+New addition\n" } + + it 'returns Chunk segment' do + expect(segment).to be_a(Gitlab::WordDiff::Segments::Chunk) + expect(segment.to_s).to eq('New addition') + end + end + end +end diff --git a/spec/lib/gitlab/word_diff/parser_spec.rb b/spec/lib/gitlab/word_diff/parser_spec.rb new file mode 100644 index 00000000000..3aeefb57a02 --- /dev/null +++ b/spec/lib/gitlab/word_diff/parser_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::WordDiff::Parser do + subject(:parser) { described_class.new } + + describe '#parse' do + subject { parser.parse(diff.lines).to_a } + + let(:diff) do + <<~EOF + @@ -1,14 +1,13 @@ + ~ + Unchanged line + ~ + ~ + -Old change + +New addition + unchanged content + ~ + @@ -50,14 +50,13 @@ + +First change + same same same_ + -removed_ + +added_ + end of the line + ~ + ~ + EOF + end + + it 'returns a collection of lines' do + diff_lines = subject + + aggregate_failures do + expect(diff_lines.count).to eq(7) + + expect(diff_lines.map(&:to_hash)).to match_array( + [ + a_hash_including(index: 0, old_pos: 1, new_pos: 1, text: '', type: nil), + a_hash_including(index: 1, old_pos: 2, new_pos: 2, text: 'Unchanged line', type: nil), + a_hash_including(index: 2, old_pos: 3, new_pos: 3, text: '', type: nil), + a_hash_including(index: 3, old_pos: 4, new_pos: 4, text: 'Old changeNew addition unchanged content', type: nil), + a_hash_including(index: 4, old_pos: 50, new_pos: 50, text: '@@ -50,14 +50,13 @@', type: 'match'), + a_hash_including(index: 5, old_pos: 50, new_pos: 50, text: 'First change same same same_removed_added_end of the line', type: nil), + a_hash_including(index: 6, old_pos: 51, new_pos: 51, text: '', type: nil) + ] + ) + end + end + + it 'restarts object index after several calls to Enumerator' do + enumerator = parser.parse(diff.lines) + + 2.times do + expect(enumerator.first.index).to eq(0) + end + end + + context 'when diff is empty' do + let(:diff) { '' } + + it { is_expected.to eq([]) } + end + end +end diff --git a/spec/lib/gitlab/word_diff/positions_counter_spec.rb b/spec/lib/gitlab/word_diff/positions_counter_spec.rb new file mode 100644 index 00000000000..e2c246f6801 --- /dev/null +++ b/spec/lib/gitlab/word_diff/positions_counter_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::WordDiff::PositionsCounter do + subject(:counter) { described_class.new } + + describe 'Initial state' do + it 'starts with predefined values' do + expect(counter.pos_old).to eq(1) + expect(counter.pos_new).to eq(1) + expect(counter.line_obj_index).to eq(0) + end + end + + describe '#increase_pos_num' do + it 'increases old and new positions' do + expect { counter.increase_pos_num }.to change { counter.pos_old }.from(1).to(2) + .and change { counter.pos_new }.from(1).to(2) + end + end + + describe '#increase_obj_index' do + it 'increases object index' do + expect { counter.increase_obj_index }.to change { counter.line_obj_index }.from(0).to(1) + end + end + + describe '#set_pos_num' do + it 'sets old and new positions' do + expect { counter.set_pos_num(old: 10, new: 12) }.to change { counter.pos_old }.from(1).to(10) + .and change { counter.pos_new }.from(1).to(12) + end + end +end diff --git a/spec/lib/gitlab/word_diff/segments/chunk_spec.rb b/spec/lib/gitlab/word_diff/segments/chunk_spec.rb new file mode 100644 index 00000000000..797cc42a03c --- /dev/null +++ b/spec/lib/gitlab/word_diff/segments/chunk_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::WordDiff::Segments::Chunk do + subject(:chunk) { described_class.new(line) } + + let(:line) { ' Hello' } + + describe '#removed?' do + subject { chunk.removed? } + + it { is_expected.to be_falsey } + + context 'when line starts with "-"' do + let(:line) { '-Removed' } + + it { is_expected.to be_truthy } + end + end + + describe '#added?' do + subject { chunk.added? } + + it { is_expected.to be_falsey } + + context 'when line starts with "+"' do + let(:line) { '+Added' } + + it { is_expected.to be_truthy } + end + end + + describe '#to_s' do + subject { chunk.to_s } + + it 'removes lead string modifier' do + is_expected.to eq('Hello') + end + + context 'when chunk is empty' do + let(:line) { '' } + + it { is_expected.to eq('') } + end + end + + describe '#length' do + subject { chunk.length } + + it { is_expected.to eq('Hello'.length) } + end +end diff --git a/spec/lib/gitlab/word_diff/segments/diff_hunk_spec.rb b/spec/lib/gitlab/word_diff/segments/diff_hunk_spec.rb new file mode 100644 index 00000000000..5250e6d73c2 --- /dev/null +++ b/spec/lib/gitlab/word_diff/segments/diff_hunk_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::WordDiff::Segments::DiffHunk do + subject(:diff_hunk) { described_class.new(line) } + + let(:line) { '@@ -3,14 +4,13 @@' } + + describe '#pos_old' do + subject { diff_hunk.pos_old } + + it { is_expected.to eq 3 } + + context 'when diff hunk is broken' do + let(:line) { '@@ ??? @@' } + + it { is_expected.to eq 0 } + end + end + + describe '#pos_new' do + subject { diff_hunk.pos_new } + + it { is_expected.to eq 4 } + + context 'when diff hunk is broken' do + let(:line) { '@@ ??? @@' } + + it { is_expected.to eq 0 } + end + end + + describe '#first_line?' do + subject { diff_hunk.first_line? } + + it { is_expected.to be_falsey } + + context 'when diff hunk located on the first line' do + let(:line) { '@@ -1,14 +1,13 @@' } + + it { is_expected.to be_truthy } + end + end + + describe '#to_s' do + subject { diff_hunk.to_s } + + it { is_expected.to eq(line) } + end +end diff --git a/spec/lib/gitlab/word_diff/segments/newline_spec.rb b/spec/lib/gitlab/word_diff/segments/newline_spec.rb new file mode 100644 index 00000000000..ed5054844f1 --- /dev/null +++ b/spec/lib/gitlab/word_diff/segments/newline_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::WordDiff::Segments::Newline do + subject(:newline) { described_class.new } + + describe '#to_s' do + subject { newline.to_s } + + it { is_expected.to eq '' } + end +end diff --git a/spec/lib/gitlab/x509/signature_spec.rb b/spec/lib/gitlab/x509/signature_spec.rb index ac6f7e49fe0..2ac9c1f3a3b 100644 --- a/spec/lib/gitlab/x509/signature_spec.rb +++ b/spec/lib/gitlab/x509/signature_spec.rb @@ -11,6 +11,65 @@ RSpec.describe Gitlab::X509::Signature do } end + shared_examples "a verified signature" do + it 'returns a verified signature if email does match' do + signature = described_class.new( + X509Helpers::User1.signed_commit_signature, + X509Helpers::User1.signed_commit_base_data, + X509Helpers::User1.certificate_email, + X509Helpers::User1.signed_commit_time + ) + + expect(signature.x509_certificate).to have_attributes(certificate_attributes) + expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes) + expect(signature.verified_signature).to be_truthy + expect(signature.verification_status).to eq(:verified) + end + + it 'returns an unverified signature if email does not match' do + signature = described_class.new( + X509Helpers::User1.signed_commit_signature, + X509Helpers::User1.signed_commit_base_data, + "gitlab@example.com", + X509Helpers::User1.signed_commit_time + ) + + expect(signature.x509_certificate).to have_attributes(certificate_attributes) + expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes) + expect(signature.verified_signature).to be_truthy + expect(signature.verification_status).to eq(:unverified) + end + + it 'returns an unverified signature if email does match and time is wrong' do + signature = described_class.new( + X509Helpers::User1.signed_commit_signature, + X509Helpers::User1.signed_commit_base_data, + X509Helpers::User1.certificate_email, + Time.new(2020, 2, 22) + ) + + expect(signature.x509_certificate).to have_attributes(certificate_attributes) + expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes) + expect(signature.verified_signature).to be_falsey + expect(signature.verification_status).to eq(:unverified) + end + + it 'returns an unverified signature if certificate is revoked' do + signature = described_class.new( + X509Helpers::User1.signed_commit_signature, + X509Helpers::User1.signed_commit_base_data, + X509Helpers::User1.certificate_email, + X509Helpers::User1.signed_commit_time + ) + + expect(signature.verification_status).to eq(:verified) + + signature.x509_certificate.revoked! + + expect(signature.verification_status).to eq(:unverified) + end + end + context 'commit signature' do let(:certificate_attributes) do { @@ -30,62 +89,25 @@ RSpec.describe Gitlab::X509::Signature do allow(OpenSSL::X509::Store).to receive(:new).and_return(store) end - it 'returns a verified signature if email does match' do - signature = described_class.new( - X509Helpers::User1.signed_commit_signature, - X509Helpers::User1.signed_commit_base_data, - X509Helpers::User1.certificate_email, - X509Helpers::User1.signed_commit_time - ) - - expect(signature.x509_certificate).to have_attributes(certificate_attributes) - expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes) - expect(signature.verified_signature).to be_truthy - expect(signature.verification_status).to eq(:verified) - end + it_behaves_like "a verified signature" + end - it 'returns an unverified signature if email does not match' do - signature = described_class.new( - X509Helpers::User1.signed_commit_signature, - X509Helpers::User1.signed_commit_base_data, - "gitlab@example.com", - X509Helpers::User1.signed_commit_time - ) + context 'with the certificate defined by OpenSSL::X509::DEFAULT_CERT_FILE' do + before do + store = OpenSSL::X509::Store.new + certificate = OpenSSL::X509::Certificate.new(X509Helpers::User1.trust_cert) + file_path = Rails.root.join("tmp/cert.pem").to_s - expect(signature.x509_certificate).to have_attributes(certificate_attributes) - expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes) - expect(signature.verified_signature).to be_truthy - expect(signature.verification_status).to eq(:unverified) - end + File.open(file_path, "wb") do |f| + f.print certificate.to_pem + end - it 'returns an unverified signature if email does match and time is wrong' do - signature = described_class.new( - X509Helpers::User1.signed_commit_signature, - X509Helpers::User1.signed_commit_base_data, - X509Helpers::User1.certificate_email, - Time.new(2020, 2, 22) - ) + stub_const("OpenSSL::X509::DEFAULT_CERT_FILE", file_path) - expect(signature.x509_certificate).to have_attributes(certificate_attributes) - expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes) - expect(signature.verified_signature).to be_falsey - expect(signature.verification_status).to eq(:unverified) + allow(OpenSSL::X509::Store).to receive(:new).and_return(store) end - it 'returns an unverified signature if certificate is revoked' do - signature = described_class.new( - X509Helpers::User1.signed_commit_signature, - X509Helpers::User1.signed_commit_base_data, - X509Helpers::User1.certificate_email, - X509Helpers::User1.signed_commit_time - ) - - expect(signature.verification_status).to eq(:verified) - - signature.x509_certificate.revoked! - - expect(signature.verification_status).to eq(:unverified) - end + it_behaves_like "a verified signature" end context 'without trusted certificate within store' do diff --git a/spec/lib/marginalia_spec.rb b/spec/lib/marginalia_spec.rb index fa0cd214c7e..2ee27fbe20c 100644 --- a/spec/lib/marginalia_spec.rb +++ b/spec/lib/marginalia_spec.rb @@ -37,26 +37,9 @@ RSpec.describe 'Marginalia spec' do } end - context 'when the feature is enabled' do - before do - stub_feature(true) - end - - it 'generates a query that includes the component and value' do - component_map.each do |component, value| - expect(recorded.log.last).to include("#{component}:#{value}") - end - end - end - - context 'when the feature is disabled' do - before do - stub_feature(false) - end - - it 'excludes annotations in generated queries' do - expect(recorded.log.last).not_to include("/*") - expect(recorded.log.last).not_to include("*/") + it 'generates a query that includes the component and value' do + component_map.each do |component, value| + expect(recorded.log.last).to include("#{component}:#{value}") end end end @@ -90,59 +73,37 @@ RSpec.describe 'Marginalia spec' do } end - context 'when the feature is enabled' do - before do - stub_feature(true) + it 'generates a query that includes the component and value' do + component_map.each do |component, value| + expect(recorded.log.last).to include("#{component}:#{value}") end + end - it 'generates a query that includes the component and value' do - component_map.each do |component, value| - expect(recorded.log.last).to include("#{component}:#{value}") - end - end - - describe 'for ActionMailer delivery jobs' do - let(:delivery_job) { MarginaliaTestMailer.first_user.deliver_later } - - let(:recorded) do - ActiveRecord::QueryRecorder.new do - delivery_job.perform_now - end - end - - let(:component_map) do - { - "application" => "sidekiq", - "jid" => delivery_job.job_id, - "job_class" => delivery_job.arguments.first - } - end + describe 'for ActionMailer delivery jobs' do + let(:delivery_job) { MarginaliaTestMailer.first_user.deliver_later } - it 'generates a query that includes the component and value' do - component_map.each do |component, value| - expect(recorded.log.last).to include("#{component}:#{value}") - end + let(:recorded) do + ActiveRecord::QueryRecorder.new do + delivery_job.perform_now end end - end - context 'when the feature is disabled' do - before do - stub_feature(false) + let(:component_map) do + { + "application" => "sidekiq", + "jid" => delivery_job.job_id, + "job_class" => delivery_job.arguments.first + } end - it 'excludes annotations in generated queries' do - expect(recorded.log.last).not_to include("/*") - expect(recorded.log.last).not_to include("*/") + it 'generates a query that includes the component and value' do + component_map.each do |component, value| + expect(recorded.log.last).to include("#{component}:#{value}") + end end end end - def stub_feature(value) - stub_feature_flags(marginalia: value) - Gitlab::Marginalia.set_enabled_from_feature_flag - end - def make_request(correlation_id) request_env = Rack::MockRequest.env_for('/') diff --git a/spec/lib/object_storage/direct_upload_spec.rb b/spec/lib/object_storage/direct_upload_spec.rb index 547bba5117a..12c6cbe03b3 100644 --- a/spec/lib/object_storage/direct_upload_spec.rb +++ b/spec/lib/object_storage/direct_upload_spec.rb @@ -224,6 +224,17 @@ RSpec.describe ObjectStorage::DirectUpload do expect(subject[:CustomPutHeaders]).to be_truthy expect(subject[:PutHeaders]).to eq({}) end + + context 'with an object with UTF-8 characters' do + let(:object_name) { 'tmp/uploads/テスト' } + + it 'returns an escaped path' do + expect(subject[:GetURL]).to start_with(storage_url) + + uri = Addressable::URI.parse(subject[:GetURL]) + expect(uri.path).to include("tmp/uploads/#{CGI.escape("テスト")}") + end + end end shared_examples 'a valid upload with multipart data' do diff --git a/spec/lib/pager_duty/webhook_payload_parser_spec.rb b/spec/lib/pager_duty/webhook_payload_parser_spec.rb index 54c61b9121c..647f19e3d3a 100644 --- a/spec/lib/pager_duty/webhook_payload_parser_spec.rb +++ b/spec/lib/pager_duty/webhook_payload_parser_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -require 'fast_spec_helper' -require 'json_schemer' +require 'spec_helper' RSpec.describe PagerDuty::WebhookPayloadParser do describe '.call' do diff --git a/spec/lib/peek/views/active_record_spec.rb b/spec/lib/peek/views/active_record_spec.rb new file mode 100644 index 00000000000..dad5a2bf461 --- /dev/null +++ b/spec/lib/peek/views/active_record_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Peek::Views::ActiveRecord, :request_store do + subject { Peek.views.find { |v| v.instance_of?(Peek::Views::ActiveRecord) } } + + let(:connection) { double(:connection) } + + let(:event_1) do + { + name: 'SQL', + sql: 'SELECT * FROM users WHERE id = 10', + cached: false, + connection: connection + } + end + + let(:event_2) do + { + name: 'SQL', + sql: 'SELECT * FROM users WHERE id = 10', + cached: true, + connection: connection + } + end + + let(:event_3) do + { + name: 'SQL', + sql: 'UPDATE users SET admin = true WHERE id = 10', + cached: false, + connection: connection + } + end + + before do + allow(Gitlab::PerformanceBar).to receive(:enabled_for_request?).and_return(true) + end + + it 'subscribes and store data into peek views' do + Timecop.freeze(2021, 2, 23, 10, 0) do + ActiveSupport::Notifications.publish('sql.active_record', Time.current, Time.current + 1.second, '1', event_1) + ActiveSupport::Notifications.publish('sql.active_record', Time.current, Time.current + 2.seconds, '2', event_2) + ActiveSupport::Notifications.publish('sql.active_record', Time.current, Time.current + 3.seconds, '3', event_3) + end + + expect(subject.results).to match( + calls: '3 (1 cached)', + duration: '6000.00ms', + warnings: ["active-record duration: 6000.0 over 3000"], + details: contain_exactly( + a_hash_including( + cached: '', + duration: 1000.0, + sql: 'SELECT * FROM users WHERE id = 10' + ), + a_hash_including( + cached: 'cached', + duration: 2000.0, + sql: 'SELECT * FROM users WHERE id = 10' + ), + a_hash_including( + cached: '', + duration: 3000.0, + sql: 'UPDATE users SET admin = true WHERE id = 10' + ) + ) + ) + end +end diff --git a/spec/lib/quality/test_level_spec.rb b/spec/lib/quality/test_level_spec.rb index 2232d47234f..32960cd571b 100644 --- a/spec/lib/quality/test_level_spec.rb +++ b/spec/lib/quality/test_level_spec.rb @@ -28,7 +28,7 @@ RSpec.describe Quality::TestLevel do context 'when level is unit' do it 'returns a pattern' do expect(subject.pattern(:unit)) - .to eq("spec/{bin,channels,config,db,dependencies,elastic,elastic_integration,experiments,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,models,policies,presenters,rack_servers,replicators,routing,rubocop,serializers,services,sidekiq,support_specs,tasks,uploaders,validators,views,workers,tooling}{,/**/}*_spec.rb") + .to eq("spec/{bin,channels,config,db,dependencies,elastic,elastic_integration,experiments,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,models,policies,presenters,rack_servers,replicators,routing,rubocop,serializers,services,sidekiq,spam,support_specs,tasks,uploaders,validators,views,workers,tooling}{,/**/}*_spec.rb") end end @@ -103,7 +103,7 @@ RSpec.describe Quality::TestLevel do context 'when level is unit' do it 'returns a regexp' do expect(subject.regexp(:unit)) - .to eq(%r{spec/(bin|channels|config|db|dependencies|elastic|elastic_integration|experiments|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|models|policies|presenters|rack_servers|replicators|routing|rubocop|serializers|services|sidekiq|support_specs|tasks|uploaders|validators|views|workers|tooling)}) + .to eq(%r{spec/(bin|channels|config|db|dependencies|elastic|elastic_integration|experiments|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|models|policies|presenters|rack_servers|replicators|routing|rubocop|serializers|services|sidekiq|spam|support_specs|tasks|uploaders|validators|views|workers|tooling)}) end end diff --git a/spec/lib/release_highlights/validator/entry_spec.rb b/spec/lib/release_highlights/validator/entry_spec.rb index da44938f165..5f7ccbf4310 100644 --- a/spec/lib/release_highlights/validator/entry_spec.rb +++ b/spec/lib/release_highlights/validator/entry_spec.rb @@ -40,8 +40,8 @@ RSpec.describe ReleaseHighlights::Validator::Entry do end it 'validates boolean value of "self-managed" and "gitlab-com"' do - allow(entry).to receive(:value_for).with('self-managed').and_return('nope') - allow(entry).to receive(:value_for).with('gitlab-com').and_return('yerp') + allow(entry).to receive(:value_for).with(:'self-managed').and_return('nope') + allow(entry).to receive(:value_for).with(:'gitlab-com').and_return('yerp') subject.valid? @@ -50,17 +50,18 @@ RSpec.describe ReleaseHighlights::Validator::Entry do end it 'validates URI of "url" and "image_url"' do - allow(entry).to receive(:value_for).with('image_url').and_return('imgur/gitlab_feature.gif') - allow(entry).to receive(:value_for).with('url').and_return('gitlab/newest_release.html') + stub_env('RSPEC_ALLOW_INVALID_URLS', 'false') + allow(entry).to receive(:value_for).with(:image_url).and_return('https://foobar.x/images/ci/gitlab-ci-cd-logo_2x.png') + allow(entry).to receive(:value_for).with(:url).and_return('') subject.valid? - expect(subject.errors[:url]).to include(/must be a URL/) - expect(subject.errors[:image_url]).to include(/must be a URL/) + expect(subject.errors[:url]).to include(/must be a valid URL/) + expect(subject.errors[:image_url]).to include(/is blocked: Host cannot be resolved or invalid/) end it 'validates release is numerical' do - allow(entry).to receive(:value_for).with('release').and_return('one') + allow(entry).to receive(:value_for).with(:release).and_return('one') subject.valid? @@ -68,7 +69,7 @@ RSpec.describe ReleaseHighlights::Validator::Entry do end it 'validates published_at is a date' do - allow(entry).to receive(:value_for).with('published_at').and_return('christmas day') + allow(entry).to receive(:value_for).with(:published_at).and_return('christmas day') subject.valid? @@ -76,7 +77,7 @@ RSpec.describe ReleaseHighlights::Validator::Entry do end it 'validates packages are included in list' do - allow(entry).to receive(:value_for).with('packages').and_return(['ALL']) + allow(entry).to receive(:value_for).with(:packages).and_return(['ALL']) subject.valid? diff --git a/spec/lib/release_highlights/validator_spec.rb b/spec/lib/release_highlights/validator_spec.rb index a423e8cc5f6..f30754b4167 100644 --- a/spec/lib/release_highlights/validator_spec.rb +++ b/spec/lib/release_highlights/validator_spec.rb @@ -78,7 +78,10 @@ RSpec.describe ReleaseHighlights::Validator do end describe 'when validating all files' do - it 'they should have no errors' do + # Permit DNS requests to validate all URLs in the YAML files + it 'they should have no errors', :permit_dns do + stub_env('RSPEC_ALLOW_INVALID_URLS', 'false') + expect(described_class.validate_all!).to be_truthy, described_class.error_message end end diff --git a/spec/lib/system_check/sidekiq_check_spec.rb b/spec/lib/system_check/sidekiq_check_spec.rb new file mode 100644 index 00000000000..c2f61e0e4b7 --- /dev/null +++ b/spec/lib/system_check/sidekiq_check_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe SystemCheck::SidekiqCheck do + describe '#multi_check' do + def stub_ps_output(output) + allow(Gitlab::Popen).to receive(:popen).with(%w(ps uxww)).and_return([output, nil]) + end + + def expect_check_output(matcher) + expect { subject.multi_check }.to output(matcher).to_stdout + end + + it 'fails when no worker processes are running' do + stub_ps_output <<~PS + root 2193947 0.9 0.1 146564 18104 ? Ssl 17:34 0:00 ruby bin/sidekiq-cluster * -P ... + PS + + expect_check_output include( + 'Running? ... no', + 'Please fix the error above and rerun the checks.' + ) + end + + it 'fails when more than one cluster process is running' do + stub_ps_output <<~PS + root 2193947 0.9 0.1 146564 18104 ? Ssl 17:34 0:00 ruby bin/sidekiq-cluster * -P ... + root 2193948 0.9 0.1 146564 18104 ? Ssl 17:34 0:00 ruby bin/sidekiq-cluster * -P ... + root 2193955 92.2 3.1 4675972 515516 ? Sl 17:34 0:13 sidekiq 5.2.9 ... + PS + + expect_check_output include( + 'Running? ... yes', + 'Number of Sidekiq processes (cluster/worker) ... 2/1', + 'Please fix the error above and rerun the checks.' + ) + end + + it 'succeeds when one cluster process and one or more worker processes are running' do + stub_ps_output <<~PS + root 2193947 0.9 0.1 146564 18104 ? Ssl 17:34 0:00 ruby bin/sidekiq-cluster * -P ... + root 2193955 92.2 3.1 4675972 515516 ? Sl 17:34 0:13 sidekiq 5.2.9 ... + root 2193956 92.2 3.1 4675972 515516 ? Sl 17:34 0:13 sidekiq 5.2.9 ... + PS + + expect_check_output <<~OUTPUT + Running? ... yes + Number of Sidekiq processes (cluster/worker) ... 1/2 + OUTPUT + end + + # TODO: Running without a cluster is deprecated and will be removed in GitLab 14.0 + # https://gitlab.com/gitlab-org/gitlab/-/issues/323225 + context 'when running without a cluster' do + it 'fails when more than one worker process is running' do + stub_ps_output <<~PS + root 2193955 92.2 3.1 4675972 515516 ? Sl 17:34 0:13 sidekiq 5.2.9 ... + root 2193956 92.2 3.1 4675972 515516 ? Sl 17:34 0:13 sidekiq 5.2.9 ... + PS + + expect_check_output include( + 'Running? ... yes', + 'Number of Sidekiq processes (cluster/worker) ... 0/2', + 'Please fix the error above and rerun the checks.' + ) + end + + it 'succeeds when one worker process is running' do + stub_ps_output <<~PS + root 2193955 92.2 3.1 4675972 515516 ? Sl 17:34 0:13 sidekiq 5.2.9 ... + PS + + expect_check_output <<~OUTPUT + Running? ... yes + Number of Sidekiq processes (cluster/worker) ... 0/1 + OUTPUT + end + end + end +end diff --git a/spec/mailers/emails/in_product_marketing_spec.rb b/spec/mailers/emails/in_product_marketing_spec.rb new file mode 100644 index 00000000000..e4157eaf5dc --- /dev/null +++ b/spec/mailers/emails/in_product_marketing_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'email_spec' + +RSpec.describe Emails::InProductMarketing do + include EmailSpec::Matchers + include InProductMarketingHelper + + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + + describe '#in_product_marketing_email' do + using RSpec::Parameterized::TableSyntax + + where(:track, :series) do + :create | 0 + :create | 1 + :create | 2 + :verify | 0 + :verify | 1 + :verify | 2 + :trial | 0 + :trial | 1 + :trial | 2 + :team | 0 + :team | 1 + :team | 2 + end + + with_them do + subject { Notify.in_product_marketing_email(user.id, group.id, track, series) } + + it 'has the correct subject and content' do + aggregate_failures do + is_expected.to have_subject(subject_line(track, series)) + is_expected.to have_body_text(in_product_marketing_title(track, series)) + is_expected.to have_body_text(in_product_marketing_subtitle(track, series)) + is_expected.to have_body_text(in_product_marketing_cta_text(track, series)) + end + end + end + end +end diff --git a/spec/mailers/emails/merge_requests_spec.rb b/spec/mailers/emails/merge_requests_spec.rb index 34665d943ab..0c0dae6d7e6 100644 --- a/spec/mailers/emails/merge_requests_spec.rb +++ b/spec/mailers/emails/merge_requests_spec.rb @@ -6,37 +6,199 @@ require 'email_spec' RSpec.describe Emails::MergeRequests do include EmailSpec::Matchers - describe "#resolved_all_discussions_email" do - let(:user) { create(:user) } - let(:merge_request) { create(:merge_request) } - let(:current_user) { create(:user) } + include_context 'gitlab email notification' + + let_it_be(:current_user, reload: true) { create(:user, email: "current@email.com", name: 'www.example.com') } + let_it_be(:assignee, reload: true) { create(:user, email: 'assignee@example.com', name: 'John Doe') } + let_it_be(:reviewer, reload: true) { create(:user, email: 'reviewer@example.com', name: 'Jane Doe') } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:merge_request) do + create(:merge_request, source_project: project, + target_project: project, + author: current_user, + assignees: [assignee], + reviewers: [reviewer], + description: 'Awesome description') + end - subject { Notify.resolved_all_discussions_email(user.id, merge_request.id, current_user.id) } + let(:recipient) { assignee } + let(:current_user_sanitized) { 'www_example_com' } - it "includes the name of the resolver" do - expect(subject).to have_body_text current_user.name + describe '#new_mention_in_merge_request_email' do + subject { Notify.new_mention_in_merge_request_email(recipient.id, merge_request.id, current_user.id) } + + it 'has the correct subject and body' do + aggregate_failures do + is_expected.to have_referable_subject(merge_request, reply: true) + is_expected.to have_body_text(project_merge_request_path(project, merge_request)) + is_expected.to have_body_text('You have been mentioned in Merge Request') + is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request)) + is_expected.to have_text_part_content(assignee.name) + is_expected.to have_text_part_content(reviewer.name) + is_expected.to have_html_part_content(assignee.name) + is_expected.to have_html_part_content(reviewer.name) + end + end + end + + describe '#merge_request_unmergeable_email' do + subject { Notify.merge_request_unmergeable_email(recipient.id, merge_request.id) } + + it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do + let(:model) { merge_request } + end + + it_behaves_like 'a multiple recipients email' + it_behaves_like 'it should show Gmail Actions View Merge request link' + it_behaves_like 'an unsubscribeable thread' + it_behaves_like 'appearance header and footer enabled' + it_behaves_like 'appearance header and footer not enabled' + + it 'is sent as the merge request author' do + sender = subject.header[:from].addrs[0] + expect(sender.display_name).to eq(merge_request.author.name) + expect(sender.address).to eq(gitlab_sender) + end + + it 'has the correct subject and body' do + aggregate_failures do + is_expected.to have_referable_subject(merge_request, reply: true) + is_expected.to have_body_text(project_merge_request_path(project, merge_request)) + is_expected.to have_body_text('due to conflict.') + is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request)) + is_expected.to have_text_part_content(assignee.name) + is_expected.to have_text_part_content(reviewer.name) + end + end + end + + describe '#closed_merge_request_email' do + subject { Notify.closed_merge_request_email(recipient.id, merge_request.id, current_user.id) } + + it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do + let(:model) { merge_request } + end + + it_behaves_like 'it should show Gmail Actions View Merge request link' + it_behaves_like 'an unsubscribeable thread' + it_behaves_like 'appearance header and footer enabled' + it_behaves_like 'appearance header and footer not enabled' + + it 'is sent as the author' do + sender = subject.header[:from].addrs[0] + expect(sender.display_name).to eq(current_user.name) + expect(sender.address).to eq(gitlab_sender) + end + + it 'has the correct subject and body' do + aggregate_failures do + is_expected.to have_referable_subject(merge_request, reply: true) + is_expected.to have_body_text('closed') + is_expected.to have_body_text(current_user_sanitized) + is_expected.to have_body_text(project_merge_request_path(project, merge_request)) + is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request)) + + expect(subject.text_part).to have_content(assignee.name) + expect(subject.text_part).to have_content(reviewer.name) + end + end + end + + describe '#merged_merge_request_email' do + let(:merge_author) { assignee } + + subject { Notify.merged_merge_request_email(recipient.id, merge_request.id, merge_author.id) } + + it_behaves_like 'a multiple recipients email' + it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do + let(:model) { merge_request } + end + + it_behaves_like 'it should show Gmail Actions View Merge request link' + it_behaves_like 'an unsubscribeable thread' + it_behaves_like 'appearance header and footer enabled' + it_behaves_like 'appearance header and footer not enabled' + + it 'is sent as the merge author' do + sender = subject.header[:from].addrs[0] + expect(sender.display_name).to eq(merge_author.name) + expect(sender.address).to eq(gitlab_sender) + end + + it 'has the correct subject and body' do + aggregate_failures do + is_expected.to have_referable_subject(merge_request, reply: true) + is_expected.to have_body_text('merged') + is_expected.to have_body_text(project_merge_request_path(project, merge_request)) + is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request)) + + expect(subject.text_part).to have_content(assignee.name) + expect(subject.text_part).to have_content(reviewer.name) + end + end + end + + describe '#merge_request_status_email' do + let(:status) { 'reopened' } + + subject { Notify.merge_request_status_email(recipient.id, merge_request.id, status, current_user.id) } + + it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do + let(:model) { merge_request } + end + + it_behaves_like 'it should show Gmail Actions View Merge request link' + it_behaves_like 'an unsubscribeable thread' + it_behaves_like 'appearance header and footer enabled' + it_behaves_like 'appearance header and footer not enabled' + + it 'is sent as the author' do + sender = subject.header[:from].addrs[0] + expect(sender.display_name).to eq(current_user.name) + expect(sender.address).to eq(gitlab_sender) + end + + it 'has the correct subject and body' do + aggregate_failures do + is_expected.to have_referable_subject(merge_request, reply: true) + is_expected.to have_body_text(status) + is_expected.to have_body_text(current_user_sanitized) + is_expected.to have_body_text(project_merge_request_path(project, merge_request)) + is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request)) + + expect(subject.text_part).to have_content(assignee.name) + expect(subject.text_part).to have_content(reviewer.name) + end end end describe "#merge_when_pipeline_succeeds_email" do - let(:user) { create(:user) } - let(:merge_request) { create(:merge_request) } - let(:current_user) { create(:user) } - let(:project) { create(:project, :repository) } let(:title) { "Merge request #{merge_request.to_reference} was scheduled to merge after pipeline succeeds by #{current_user.name}" } - subject { Notify.merge_when_pipeline_succeeds_email(user.id, merge_request.id, current_user.id) } + subject { Notify.merge_when_pipeline_succeeds_email(recipient.id, merge_request.id, current_user.id) } it "has required details" do - expect(subject).to have_content title - expect(subject).to have_content merge_request.to_reference - expect(subject).to have_content current_user.name + aggregate_failures do + is_expected.to have_content(title) + is_expected.to have_content(merge_request.to_reference) + is_expected.to have_content(current_user.name) + is_expected.to have_text_part_content(assignee.name) + is_expected.to have_html_part_content(assignee.name) + is_expected.to have_text_part_content(reviewer.name) + is_expected.to have_html_part_content(reviewer.name) + end + end + end + + describe "#resolved_all_discussions_email" do + subject { Notify.resolved_all_discussions_email(recipient.id, merge_request.id, current_user.id) } + + it "includes the name of the resolver" do + expect(subject).to have_body_text current_user_sanitized end end describe '#merge_requests_csv_email' do - let(:user) { create(:user) } - let(:project) { create(:project) } let(:merge_requests) { create_list(:merge_request, 10) } let(:export_status) do { @@ -48,10 +210,10 @@ RSpec.describe Emails::MergeRequests do let(:csv_data) { MergeRequests::ExportCsvService.new(MergeRequest.all, project).csv_data } - subject { Notify.merge_requests_csv_email(user, project, csv_data, export_status) } + subject { Notify.merge_requests_csv_email(recipient, project, csv_data, export_status) } it { expect(subject.subject).to eq("#{project.name} | Exported merge requests") } - it { expect(subject.to).to contain_exactly(user.notification_email_for(project.group)) } + it { expect(subject.to).to contain_exactly(recipient.notification_email_for(project.group)) } it { expect(subject.html_part).to have_content("Your CSV export of 10 merge requests from project") } it { expect(subject.text_part).to have_content("Your CSV export of 10 merge requests from project") } diff --git a/spec/mailers/emails/pipelines_spec.rb b/spec/mailers/emails/pipelines_spec.rb index 3ac68721357..a29835f3439 100644 --- a/spec/mailers/emails/pipelines_spec.rb +++ b/spec/mailers/emails/pipelines_spec.rb @@ -89,7 +89,7 @@ RSpec.describe Emails::Pipelines do let(:sha) { project.commit(ref).sha } it_behaves_like 'correct pipeline information' do - let(:status) { 'Succesful' } + let(:status) { 'Successful' } let(:status_text) { "Pipeline ##{pipeline.id} has passed!" } end end diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb index fdff2d837f8..a32e566fc90 100644 --- a/spec/mailers/emails/profile_spec.rb +++ b/spec/mailers/emails/profile_spec.rb @@ -125,8 +125,9 @@ RSpec.describe Emails::Profile do describe 'user personal access token is about to expire' do let_it_be(:user) { create(:user) } + let_it_be(:expiring_token) { create(:personal_access_token, user: user, expires_at: 5.days.from_now) } - subject { Notify.access_token_about_to_expire_email(user) } + subject { Notify.access_token_about_to_expire_email(user, [expiring_token.name]) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' @@ -137,13 +138,17 @@ RSpec.describe Emails::Profile do end it 'has the correct subject' do - is_expected.to have_subject /^Your Personal Access Tokens will expire in 7 days or less$/i + is_expected.to have_subject /^Your personal access tokens will expire in 7 days or less$/i end it 'mentions the access tokens will expire' do is_expected.to have_body_text /One or more of your personal access tokens will expire in 7 days or less/ end + it 'provides the names of expiring tokens' do + is_expected.to have_body_text /#{expiring_token.name}/ + end + it 'includes a link to personal access tokens page' do is_expected.to have_body_text /#{profile_personal_access_tokens_path}/ end diff --git a/spec/mailers/emails/service_desk_spec.rb b/spec/mailers/emails/service_desk_spec.rb index 7d04b373be6..cb74194020d 100644 --- a/spec/mailers/emails/service_desk_spec.rb +++ b/spec/mailers/emails/service_desk_spec.rb @@ -13,8 +13,13 @@ RSpec.describe Emails::ServiceDesk do let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project) } let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:email) { 'someone@gitlab.com' } let(:template) { double(content: template_content) } + before_all do + issue.issue_email_participants.create!(email: email) + end + before do stub_const('ServiceEmailClass', Class.new(ApplicationMailer)) @@ -72,6 +77,10 @@ RSpec.describe Emails::ServiceDesk do let(:template_content) { 'custom text' } let(:issue) { create(:issue, project: project)} + before do + issue.issue_email_participants.create!(email: email) + end + context 'when a template is in the repository' do let(:project) { create(:project, :custom_repo, files: { ".gitlab/service_desk_templates/#{template_key}.md" => template_content }) } @@ -151,7 +160,7 @@ RSpec.describe Emails::ServiceDesk do let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project) } let_it_be(:default_text) { note.note } - subject { ServiceEmailClass.service_desk_new_note_email(issue.id, note.id) } + subject { ServiceEmailClass.service_desk_new_note_email(issue.id, note.id, email) } it_behaves_like 'read template from repository', 'new_note' diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 89cf1aaedd2..79358d3e40c 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -16,12 +16,14 @@ RSpec.describe Notify do let_it_be(:user, reload: true) { create(:user) } let_it_be(:current_user, reload: true) { create(:user, email: "current@email.com", name: 'www.example.com') } let_it_be(:assignee, reload: true) { create(:user, email: 'assignee@example.com', name: 'John Doe') } + let_it_be(:reviewer, reload: true) { create(:user, email: 'reviewer@example.com', name: 'Jane Doe') } let_it_be(:merge_request) do create(:merge_request, source_project: project, target_project: project, author: current_user, assignees: [assignee], + reviewers: [reviewer], description: 'Awesome description') end @@ -342,6 +344,7 @@ RSpec.describe Notify do is_expected.to have_body_text(project_merge_request_path(project, merge_request)) is_expected.to have_body_text(merge_request.source_branch) is_expected.to have_body_text(merge_request.target_branch) + is_expected.to have_body_text(reviewer.name) end end @@ -362,7 +365,11 @@ RSpec.describe Notify do it 'contains a link to merge request author' do is_expected.to have_body_text merge_request.author_name - is_expected.to have_body_text 'created a merge request:' + is_expected.to have_body_text 'created a' + end + + it 'contains a link to the merge request url' do + is_expected.to have_link('merge request', href: project_merge_request_url(merge_request.target_project, merge_request)) end end @@ -461,106 +468,6 @@ RSpec.describe Notify do end end - describe 'status changed' do - let(:status) { 'reopened' } - - subject { described_class.merge_request_status_email(recipient.id, merge_request.id, status, current_user.id) } - - it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do - let(:model) { merge_request } - end - - it_behaves_like 'it should show Gmail Actions View Merge request link' - it_behaves_like 'an unsubscribeable thread' - it_behaves_like 'appearance header and footer enabled' - it_behaves_like 'appearance header and footer not enabled' - - it 'is sent as the author' do - sender = subject.header[:from].addrs[0] - expect(sender.display_name).to eq(current_user.name) - expect(sender.address).to eq(gitlab_sender) - end - - it 'has the correct subject and body' do - aggregate_failures do - is_expected.to have_referable_subject(merge_request, reply: true) - is_expected.to have_body_text(status) - is_expected.to have_body_text(current_user_sanitized) - is_expected.to have_body_text(project_merge_request_path(project, merge_request)) - is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request)) - end - end - end - - describe 'that are merged' do - let(:merge_author) { create(:user) } - - subject { described_class.merged_merge_request_email(recipient.id, merge_request.id, merge_author.id) } - - it_behaves_like 'a multiple recipients email' - it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do - let(:model) { merge_request } - end - - it_behaves_like 'it should show Gmail Actions View Merge request link' - it_behaves_like 'an unsubscribeable thread' - it_behaves_like 'appearance header and footer enabled' - it_behaves_like 'appearance header and footer not enabled' - - it 'is sent as the merge author' do - sender = subject.header[:from].addrs[0] - expect(sender.display_name).to eq(merge_author.name) - expect(sender.address).to eq(gitlab_sender) - end - - it 'has the correct subject and body' do - aggregate_failures do - is_expected.to have_referable_subject(merge_request, reply: true) - is_expected.to have_body_text('merged') - is_expected.to have_body_text(project_merge_request_path(project, merge_request)) - is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request)) - end - end - end - - describe 'that are unmergeable' do - let_it_be(:merge_request) do - create(:merge_request, :conflict, - source_project: project, - target_project: project, - author: current_user, - assignees: [assignee], - description: 'Awesome description') - end - - subject { described_class.merge_request_unmergeable_email(recipient.id, merge_request.id) } - - it_behaves_like 'a multiple recipients email' - it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do - let(:model) { merge_request } - end - - it_behaves_like 'it should show Gmail Actions View Merge request link' - it_behaves_like 'an unsubscribeable thread' - it_behaves_like 'appearance header and footer enabled' - it_behaves_like 'appearance header and footer not enabled' - - it 'is sent as the merge request author' do - sender = subject.header[:from].addrs[0] - expect(sender.display_name).to eq(merge_request.author.name) - expect(sender.address).to eq(gitlab_sender) - end - - it 'has the correct subject and body' do - aggregate_failures do - is_expected.to have_referable_subject(merge_request, reply: true) - is_expected.to have_body_text(project_merge_request_path(project, merge_request)) - is_expected.to have_body_text('due to conflict.') - is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request)) - end - end - end - shared_examples 'a push to an existing merge request' do let(:push_user) { create(:user) } @@ -1311,6 +1218,7 @@ RSpec.describe Notify do context 'for service desk issues' do before do issue.update!(external_author: 'service.desk@example.com') + issue.issue_email_participants.create!(email: 'service.desk@example.com') end def expect_sender(username) @@ -1359,7 +1267,7 @@ RSpec.describe Notify do describe 'new note email' do let_it_be(:first_note) { create(:discussion_note_on_issue, note: 'Hello world') } - subject { described_class.service_desk_new_note_email(issue.id, first_note.id) } + subject { described_class.service_desk_new_note_email(issue.id, first_note.id, 'service.desk@example.com') } it_behaves_like 'an unsubscribeable thread' diff --git a/spec/migrations/20201112130715_schedule_recalculate_uuid_on_vulnerabilities_occurrences_spec.rb b/spec/migrations/20201112130715_schedule_recalculate_uuid_on_vulnerabilities_occurrences_spec.rb new file mode 100644 index 00000000000..fce32be4683 --- /dev/null +++ b/spec/migrations/20201112130715_schedule_recalculate_uuid_on_vulnerabilities_occurrences_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20201112130715_schedule_recalculate_uuid_on_vulnerabilities_occurrences.rb') + +RSpec.describe ScheduleRecalculateUuidOnVulnerabilitiesOccurrences, :migration do + let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } + let(:users) { table(:users) } + let(:user) { create_user! } + let(:project) { table(:projects).create!(id: 123, namespace_id: namespace.id) } + let(:scanners) { table(:vulnerability_scanners) } + let(:scanner) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') } + let(:different_scanner) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') } + let(:vulnerabilities) { table(:vulnerabilities) } + let(:vulnerabilities_findings) { table(:vulnerability_occurrences) } + let(:vulnerability_identifiers) { table(:vulnerability_identifiers) } + let(:vulnerability_identifier) do + vulnerability_identifiers.create!( + project_id: project.id, + external_type: 'uuid-v5', + external_id: 'uuid-v5', + fingerprint: '7e394d1b1eb461a7406d7b1e08f057a1cf11287a', + name: 'Identifier for UUIDv5') + end + + let(:different_vulnerability_identifier) do + vulnerability_identifiers.create!( + project_id: project.id, + external_type: 'uuid-v4', + external_id: 'uuid-v4', + fingerprint: '772da93d34a1ba010bcb5efa9fb6f8e01bafcc89', + name: 'Identifier for UUIDv4') + end + + let!(:vulnerability_for_uuidv4) do + create_vulnerability!( + project_id: project.id, + author_id: user.id + ) + end + + let!(:vulnerability_for_uuidv5) do + create_vulnerability!( + project_id: project.id, + author_id: user.id + ) + end + + let(:known_uuid_v4) { "b3cc2518-5446-4dea-871c-89d5e999c1ac" } + let!(:finding_with_uuid_v4) do + create_finding!( + vulnerability_id: vulnerability_for_uuidv4.id, + project_id: project.id, + scanner_id: different_scanner.id, + primary_identifier_id: different_vulnerability_identifier.id, + report_type: 0, # "sast" + location_fingerprint: "fa18f432f1d56675f4098d318739c3cd5b14eb3e", + uuid: known_uuid_v4 + ) + end + + let(:known_uuid_v5) { "e7d3d99d-04bb-5771-bb44-d80a9702d0a2" } + let!(:finding_with_uuid_v5) do + create_finding!( + vulnerability_id: vulnerability_for_uuidv5.id, + project_id: project.id, + scanner_id: scanner.id, + primary_identifier_id: vulnerability_identifier.id, + report_type: 0, # "sast" + location_fingerprint: "838574be0210968bf6b9f569df9c2576242cbf0a", + uuid: known_uuid_v5 + ) + end + + before do + stub_const("#{described_class}::BATCH_SIZE", 1) + end + + around do |example| + freeze_time { Sidekiq::Testing.fake! { example.run } } + end + + it 'schedules background migration' do + migrate! + + expect(BackgroundMigrationWorker.jobs.size).to eq(2) + expect(described_class::MIGRATION).to be_scheduled_migration(finding_with_uuid_v4.id, finding_with_uuid_v4.id) + expect(described_class::MIGRATION).to be_scheduled_migration(finding_with_uuid_v5.id, finding_with_uuid_v5.id) + end + + private + + def create_vulnerability!(project_id:, author_id:, title: 'test', severity: 7, confidence: 7, report_type: 0) + vulnerabilities.create!( + project_id: project_id, + author_id: author_id, + title: title, + severity: severity, + confidence: confidence, + report_type: report_type + ) + end + + # rubocop:disable Metrics/ParameterLists + def create_finding!( + vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:, + name: "test", severity: 7, confidence: 7, report_type: 0, + project_fingerprint: '123qweasdzxc', location_fingerprint: 'test', + metadata_version: 'test', raw_metadata: 'test', uuid: 'test') + vulnerabilities_findings.create!( + vulnerability_id: vulnerability_id, + project_id: project_id, + name: name, + severity: severity, + confidence: confidence, + report_type: report_type, + project_fingerprint: project_fingerprint, + scanner_id: scanner.id, + primary_identifier_id: vulnerability_identifier.id, + location_fingerprint: location_fingerprint, + metadata_version: metadata_version, + raw_metadata: raw_metadata, + uuid: uuid + ) + end + # rubocop:enable Metrics/ParameterLists + + def create_user!(name: "Example User", email: "user@example.com", user_type: nil, created_at: Time.now, confirmed_at: Time.now) + users.create!( + name: name, + email: email, + username: name, + projects_limit: 0, + user_type: user_type, + confirmed_at: confirmed_at + ) + end +end diff --git a/spec/migrations/20210218040814_add_environment_scope_to_group_variables_spec.rb b/spec/migrations/20210218040814_add_environment_scope_to_group_variables_spec.rb new file mode 100644 index 00000000000..e525101f3a0 --- /dev/null +++ b/spec/migrations/20210218040814_add_environment_scope_to_group_variables_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration!('add_environment_scope_to_group_variables') + +RSpec.describe AddEnvironmentScopeToGroupVariables do + let(:migration) { described_class.new } + let(:ci_group_variables) { table(:ci_group_variables) } + let(:group) { table(:namespaces).create!(name: 'group', path: 'group') } + + def create_variable!(group, key:, environment_scope: '*') + table(:ci_group_variables).create!( + group_id: group.id, + key: key, + environment_scope: environment_scope + ) + end + + describe '#down' do + context 'group has variables with duplicate keys' do + it 'deletes all but the first record' do + migration.up + + remaining_variable = create_variable!(group, key: 'key') + create_variable!(group, key: 'key', environment_scope: 'staging') + create_variable!(group, key: 'key', environment_scope: 'production') + + migration.down + + expect(ci_group_variables.pluck(:id)).to eq [remaining_variable.id] + end + end + + context 'group does not have variables with duplicate keys' do + it 'does not delete any records' do + migration.up + + create_variable!(group, key: 'key') + create_variable!(group, key: 'staging') + create_variable!(group, key: 'production') + + expect { migration.down }.not_to change { ci_group_variables.count } + end + end + end +end diff --git a/spec/migrations/cleanup_projects_with_bad_has_external_issue_tracker_data_spec.rb b/spec/migrations/cleanup_projects_with_bad_has_external_issue_tracker_data_spec.rb new file mode 100644 index 00000000000..8aedd1f9607 --- /dev/null +++ b/spec/migrations/cleanup_projects_with_bad_has_external_issue_tracker_data_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_migration! + +RSpec.describe CleanupProjectsWithBadHasExternalIssueTrackerData, :migration do + let(:namespace) { table(:namespaces).create!(name: 'foo', path: 'bar') } + let(:projects) { table(:projects) } + let(:services) { table(:services) } + + def create_projects!(num) + Array.new(num) do + projects.create!(namespace_id: namespace.id) + end + end + + def create_active_external_issue_tracker_integrations!(*projects) + projects.each do |project| + services.create!(category: 'issue_tracker', project_id: project.id, active: true) + end + end + + def create_disabled_external_issue_tracker_integrations!(*projects) + projects.each do |project| + services.create!(category: 'issue_tracker', project_id: project.id, active: false) + end + end + + def create_active_other_integrations!(*projects) + projects.each do |project| + services.create!(category: 'not_an_issue_tracker', project_id: project.id, active: true) + end + end + + it 'sets `projects.has_external_issue_tracker` correctly' do + allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false) + + project_with_an_external_issue_tracker_1, + project_with_an_external_issue_tracker_2, + project_with_only_a_disabled_external_issue_tracker_1, + project_with_only_a_disabled_external_issue_tracker_2, + project_without_any_external_issue_trackers_1, + project_without_any_external_issue_trackers_2 = create_projects!(6) + + create_active_external_issue_tracker_integrations!( + project_with_an_external_issue_tracker_1, + project_with_an_external_issue_tracker_2 + ) + + create_disabled_external_issue_tracker_integrations!( + project_with_an_external_issue_tracker_1, + project_with_an_external_issue_tracker_2, + project_with_only_a_disabled_external_issue_tracker_1, + project_with_only_a_disabled_external_issue_tracker_2 + ) + + create_active_other_integrations!( + project_with_an_external_issue_tracker_1, + project_with_an_external_issue_tracker_2, + project_without_any_external_issue_trackers_1, + project_without_any_external_issue_trackers_2 + ) + + # PG triggers on the services table added in + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/51852 will have set + # the `has_external_issue_tracker` columns to correct data when the services + # records were created above. + # + # We set the `has_external_issue_tracker` columns for projects to incorrect + # data manually below to emulate projects in a state before the PG + # triggers were added. + project_with_an_external_issue_tracker_2.update!(has_external_issue_tracker: false) + project_with_only_a_disabled_external_issue_tracker_2.update!(has_external_issue_tracker: true) + project_without_any_external_issue_trackers_2.update!(has_external_issue_tracker: true) + + migrate! + + expected_true = [ + project_with_an_external_issue_tracker_1, + project_with_an_external_issue_tracker_2 + ].each(&:reload).map(&:has_external_issue_tracker) + + expected_not_true = [ + project_without_any_external_issue_trackers_1, + project_without_any_external_issue_trackers_2, + project_with_only_a_disabled_external_issue_tracker_1, + project_with_only_a_disabled_external_issue_tracker_2 + ].each(&:reload).map(&:has_external_issue_tracker) + + expect(expected_true).to all(eq(true)) + expect(expected_not_true).to all(be_falsey) + end +end diff --git a/spec/migrations/migrate_delayed_project_removal_from_namespaces_to_namespace_settings_spec.rb b/spec/migrations/migrate_delayed_project_removal_from_namespaces_to_namespace_settings_spec.rb new file mode 100644 index 00000000000..28a8dcf0d4c --- /dev/null +++ b/spec/migrations/migrate_delayed_project_removal_from_namespaces_to_namespace_settings_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require Rails.root.join('db', 'post_migrate', '20210215095328_migrate_delayed_project_removal_from_namespaces_to_namespace_settings.rb') + +RSpec.describe MigrateDelayedProjectRemovalFromNamespacesToNamespaceSettings, :migration do + let(:namespaces) { table(:namespaces) } + let(:namespace_settings) { table(:namespace_settings) } + + let!(:namespace_wo_settings) { namespaces.create!(name: generate(:name), path: generate(:name), delayed_project_removal: true) } + let!(:namespace_wo_settings_delay_false) { namespaces.create!(name: generate(:name), path: generate(:name), delayed_project_removal: false) } + let!(:namespace_w_settings_delay_true) { namespaces.create!(name: generate(:name), path: generate(:name), delayed_project_removal: true) } + let!(:namespace_w_settings_delay_false) { namespaces.create!(name: generate(:name), path: generate(:name), delayed_project_removal: false) } + + let!(:namespace_settings_delay_true) { namespace_settings.create!(namespace_id: namespace_w_settings_delay_true.id, delayed_project_removal: false, created_at: DateTime.now, updated_at: DateTime.now) } + let!(:namespace_settings_delay_false) { namespace_settings.create!(namespace_id: namespace_w_settings_delay_false.id, delayed_project_removal: false, created_at: DateTime.now, updated_at: DateTime.now) } + + it 'migrates delayed_project_removal to namespace_settings' do + disable_migrations_output { migrate! } + + expect(namespace_settings.count).to eq(3) + + expect(namespace_settings.find_by(namespace_id: namespace_wo_settings.id).delayed_project_removal).to eq(true) + expect(namespace_settings.find_by(namespace_id: namespace_wo_settings_delay_false.id)).to be_nil + + expect(namespace_settings_delay_true.reload.delayed_project_removal).to eq(true) + expect(namespace_settings_delay_false.reload.delayed_project_removal).to eq(false) + end +end diff --git a/spec/migrations/move_container_registry_enabled_to_project_features_spec.rb b/spec/migrations/move_container_registry_enabled_to_project_features_spec.rb new file mode 100644 index 00000000000..c7b07f3ef37 --- /dev/null +++ b/spec/migrations/move_container_registry_enabled_to_project_features_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20210226120851_move_container_registry_enabled_to_project_features.rb') + +RSpec.describe MoveContainerRegistryEnabledToProjectFeatures, :migration do + let(:namespace) { table(:namespaces).create!(name: 'gitlab', path: 'gitlab-org') } + + let!(:projects) do + [ + table(:projects).create!(namespace_id: namespace.id, name: 'project 1'), + table(:projects).create!(namespace_id: namespace.id, name: 'project 2'), + table(:projects).create!(namespace_id: namespace.id, name: 'project 3'), + table(:projects).create!(namespace_id: namespace.id, name: 'project 4') + ] + end + + before do + stub_const("#{described_class.name}::BATCH_SIZE", 3) + end + + around do |example| + Sidekiq::Testing.fake! do + freeze_time do + example.call + end + end + end + + it 'schedules jobs for ranges of projects' do + migrate! + + expect(described_class::MIGRATION) + .to be_scheduled_delayed_migration(2.minutes, projects[0].id, projects[2].id) + + expect(described_class::MIGRATION) + .to be_scheduled_delayed_migration(4.minutes, projects[3].id, projects[3].id) + end + + it 'schedules jobs according to the configured batch size' do + expect { migrate! }.to change { BackgroundMigrationWorker.jobs.size }.by(2) + end +end diff --git a/spec/migrations/reschedule_artifact_expiry_backfill_spec.rb b/spec/migrations/reschedule_artifact_expiry_backfill_spec.rb new file mode 100644 index 00000000000..532035849cb --- /dev/null +++ b/spec/migrations/reschedule_artifact_expiry_backfill_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require Rails.root.join('db', 'post_migrate', '20210224150506_reschedule_artifact_expiry_backfill.rb') + +RSpec.describe RescheduleArtifactExpiryBackfill, :migration do + let(:migration_class) { Gitlab::BackgroundMigration::BackfillArtifactExpiryDate } + let(:migration_name) { migration_class.to_s.demodulize } + + before do + table(:namespaces).create!(id: 123, name: 'test_namespace', path: 'test_namespace') + table(:projects).create!(id: 123, name: 'sample_project', path: 'sample_project', namespace_id: 123) + end + + it 'correctly schedules background migrations' do + first_artifact = create_artifact(job_id: 0, expire_at: nil, created_at: Date.new(2020, 06, 21)) + second_artifact = create_artifact(job_id: 1, expire_at: nil, created_at: Date.new(2020, 06, 21)) + create_artifact(job_id: 2, expire_at: Date.yesterday, created_at: Date.new(2020, 06, 21)) + create_artifact(job_id: 3, expire_at: nil, created_at: Date.new(2020, 06, 23)) + + Sidekiq::Testing.fake! do + freeze_time do + migrate! + + expect(BackgroundMigrationWorker.jobs.size).to eq(1) + expect(migration_name).to be_scheduled_migration_with_multiple_args(first_artifact.id, second_artifact.id) + end + end + end + + private + + def create_artifact(params) + table(:ci_builds).create!(id: params[:job_id], project_id: 123) + table(:ci_job_artifacts).create!(project_id: 123, file_type: 1, **params) + end +end diff --git a/spec/migrations/reschedule_set_default_iteration_cadences_spec.rb b/spec/migrations/reschedule_set_default_iteration_cadences_spec.rb new file mode 100644 index 00000000000..fb629c90d9f --- /dev/null +++ b/spec/migrations/reschedule_set_default_iteration_cadences_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe RescheduleSetDefaultIterationCadences do + let(:namespaces) { table(:namespaces) } + let(:iterations) { table(:sprints) } + + let(:group_1) { namespaces.create!(name: 'test_1', path: 'test_1') } + let!(:group_2) { namespaces.create!(name: 'test_2', path: 'test_2') } + let(:group_3) { namespaces.create!(name: 'test_3', path: 'test_3') } + let(:group_4) { namespaces.create!(name: 'test_4', path: 'test_4') } + let(:group_5) { namespaces.create!(name: 'test_5', path: 'test_5') } + let(:group_6) { namespaces.create!(name: 'test_6', path: 'test_6') } + let(:group_7) { namespaces.create!(name: 'test_7', path: 'test_7') } + let(:group_8) { namespaces.create!(name: 'test_8', path: 'test_8') } + + let!(:iteration_1) { iterations.create!(iid: 1, title: 'iteration 1', group_id: group_1.id, start_date: 2.days.from_now, due_date: 3.days.from_now) } + let!(:iteration_2) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_3.id, start_date: 2.days.from_now, due_date: 3.days.from_now) } + let!(:iteration_3) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_4.id, start_date: 2.days.from_now, due_date: 3.days.from_now) } + let!(:iteration_4) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_5.id, start_date: 2.days.from_now, due_date: 3.days.from_now) } + let!(:iteration_5) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_6.id, start_date: 2.days.from_now, due_date: 3.days.from_now) } + let!(:iteration_6) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_7.id, start_date: 2.days.from_now, due_date: 3.days.from_now) } + let!(:iteration_7) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_8.id, start_date: 2.days.from_now, due_date: 3.days.from_now) } + + around do |example| + freeze_time { Sidekiq::Testing.fake! { example.run } } + end + + it 'schedules the background jobs', :aggregate_failures do + stub_const("#{described_class.name}::BATCH_SIZE", 3) + + migrate! + + expect(BackgroundMigrationWorker.jobs.size).to be(3) + expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(2.minutes, group_1.id, group_3.id, group_4.id) + expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(4.minutes, group_5.id, group_6.id, group_7.id) + expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(6.minutes, group_8.id) + end +end diff --git a/spec/migrations/schedule_merge_request_assignees_migration_progress_check_spec.rb b/spec/migrations/schedule_merge_request_assignees_migration_progress_check_spec.rb deleted file mode 100644 index 0a69f49f10d..00000000000 --- a/spec/migrations/schedule_merge_request_assignees_migration_progress_check_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require Rails.root.join('db', 'post_migrate', '20190402224749_schedule_merge_request_assignees_migration_progress_check.rb') - -RSpec.describe ScheduleMergeRequestAssigneesMigrationProgressCheck do - describe '#up' do - it 'schedules MergeRequestAssigneesMigrationProgressCheck background job' do - expect(BackgroundMigrationWorker).to receive(:perform_async) - .with(described_class::MIGRATION) - .and_call_original - - subject.up - end - end -end diff --git a/spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb b/spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb index 8678361fc64..faa993dfbc7 100644 --- a/spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb +++ b/spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb @@ -6,11 +6,15 @@ require Rails.root.join('db', 'post_migrate', '20200714075739_schedule_populate_ RSpec.describe SchedulePopulatePersonalSnippetStatistics do let(:users) { table(:users) } + let(:namespaces) { table(:namespaces) } let(:snippets) { table(:snippets) } let(:projects) { table(:projects) } - let(:user1) { users.create!(id: 1, email: 'user1@example.com', projects_limit: 10, username: 'test1', name: 'Test1', state: 'active') } - let(:user2) { users.create!(id: 2, email: 'user2@example.com', projects_limit: 10, username: 'test2', name: 'Test2', state: 'active') } - let(:user3) { users.create!(id: 3, email: 'user3@example.com', projects_limit: 10, username: 'test3', name: 'Test3', state: 'active') } + let!(:user1) { users.create!(id: 1, email: 'user1@example.com', projects_limit: 10, username: 'test1', name: 'Test1', state: 'active') } + let!(:user2) { users.create!(id: 2, email: 'user2@example.com', projects_limit: 10, username: 'test2', name: 'Test2', state: 'active') } + let!(:user3) { users.create!(id: 3, email: 'user3@example.com', projects_limit: 10, username: 'test3', name: 'Test3', state: 'active') } + let!(:namespace1) { namespaces.create!(id: 1, owner_id: user1.id, name: 'test1', path: 'test1') } + let!(:namespace2) { namespaces.create!(id: 2, owner_id: user2.id, name: 'test2', path: 'test2') } + let!(:namespace3) { namespaces.create!(id: 3, owner_id: user3.id, name: 'test3', path: 'test3') } def create_snippet(id, user_id, type = 'PersonalSnippet') params = { diff --git a/spec/models/alert_management/http_integration_spec.rb b/spec/models/alert_management/http_integration_spec.rb index ddd65e723eb..f88a66a7c27 100644 --- a/spec/models/alert_management/http_integration_spec.rb +++ b/spec/models/alert_management/http_integration_spec.rb @@ -81,6 +81,32 @@ RSpec.describe AlertManagement::HttpIntegration do end end + describe 'before validation' do + describe '#ensure_payload_example_not_nil' do + subject(:integration) { build(:alert_management_http_integration, payload_example: payload_example) } + + context 'when the payload_example is nil' do + let(:payload_example) { nil } + + it 'sets the payload_example to empty JSON' do + integration.valid? + + expect(integration.payload_example).to eq({}) + end + end + + context 'when the payload_example is not nil' do + let(:payload_example) { { 'key' => 'value' } } + + it 'sets the payload_example to specified value' do + integration.valid? + + expect(integration.payload_example).to eq(payload_example) + end + end + end + end + describe '#token' do subject { integration.token } diff --git a/spec/models/analytics/instance_statistics/measurement_spec.rb b/spec/models/analytics/usage_trends/measurement_spec.rb index dbb16c5ffbe..d9a6b70c87a 100644 --- a/spec/models/analytics/instance_statistics/measurement_spec.rb +++ b/spec/models/analytics/usage_trends/measurement_spec.rb @@ -2,9 +2,9 @@ require 'spec_helper' -RSpec.describe Analytics::InstanceStatistics::Measurement, type: :model do +RSpec.describe Analytics::UsageTrends::Measurement, type: :model do describe 'validation' do - let!(:measurement) { create(:instance_statistics_measurement) } + let!(:measurement) { create(:usage_trends_measurement) } it { is_expected.to validate_presence_of(:recorded_at) } it { is_expected.to validate_presence_of(:identifier) } @@ -33,9 +33,9 @@ RSpec.describe Analytics::InstanceStatistics::Measurement, type: :model do end describe 'scopes' do - let_it_be(:measurement_1) { create(:instance_statistics_measurement, :project_count, recorded_at: 10.days.ago) } - let_it_be(:measurement_2) { create(:instance_statistics_measurement, :project_count, recorded_at: 2.days.ago) } - let_it_be(:measurement_3) { create(:instance_statistics_measurement, :group_count, recorded_at: 5.days.ago) } + let_it_be(:measurement_1) { create(:usage_trends_measurement, :project_count, recorded_at: 10.days.ago) } + let_it_be(:measurement_2) { create(:usage_trends_measurement, :project_count, recorded_at: 2.days.ago) } + let_it_be(:measurement_3) { create(:usage_trends_measurement, :group_count, recorded_at: 5.days.ago) } describe '.order_by_latest' do subject { described_class.order_by_latest } @@ -101,15 +101,15 @@ RSpec.describe Analytics::InstanceStatistics::Measurement, type: :model do describe '.find_latest_or_fallback' do subject(:count) { described_class.find_latest_or_fallback(:pipelines_skipped).count } - context 'with instance statistics' do - let!(:measurement) { create(:instance_statistics_measurement, :pipelines_skipped_count) } + context 'with usage statistics' do + let!(:measurement) { create(:usage_trends_measurement, :pipelines_skipped_count) } it 'returns the latest stored measurement' do expect(count).to eq measurement.count end end - context 'without instance statistics' do + context 'without usage statistics' do it 'returns the realtime query of the measurement' do expect(count).to eq 0 end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 5658057f588..808932ce7e4 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -105,14 +105,14 @@ RSpec.describe ApplicationSetting do it { is_expected.not_to allow_value(false).for(:hashed_storage_enabled) } - it { is_expected.not_to allow_value(101).for(:repository_storages_weighted_default) } - it { is_expected.to allow_value('90').for(:repository_storages_weighted_default) } - it { is_expected.not_to allow_value(-1).for(:repository_storages_weighted_default) } - it { is_expected.to allow_value(100).for(:repository_storages_weighted_default) } - it { is_expected.to allow_value(0).for(:repository_storages_weighted_default) } - it { is_expected.to allow_value(50).for(:repository_storages_weighted_default) } - it { is_expected.to allow_value(nil).for(:repository_storages_weighted_default) } - it { is_expected.not_to allow_value({ default: 100, shouldntexist: 50 }).for(:repository_storages_weighted) } + it { is_expected.to allow_value('default' => 0).for(:repository_storages_weighted) } + it { is_expected.to allow_value('default' => 50).for(:repository_storages_weighted) } + it { is_expected.to allow_value('default' => 100).for(:repository_storages_weighted) } + it { is_expected.to allow_value('default' => '90').for(:repository_storages_weighted) } + it { is_expected.to allow_value('default' => nil).for(:repository_storages_weighted) } + it { is_expected.not_to allow_value('default' => -1).for(:repository_storages_weighted).with_message("value for 'default' must be between 0 and 100") } + it { is_expected.not_to allow_value('default' => 101).for(:repository_storages_weighted).with_message("value for 'default' must be between 0 and 100") } + it { is_expected.not_to allow_value('default' => 100, shouldntexist: 50).for(:repository_storages_weighted).with_message("can't include: shouldntexist") } it { is_expected.to allow_value(400).for(:notes_create_limit) } it { is_expected.not_to allow_value('two').for(:notes_create_limit) } @@ -377,7 +377,7 @@ RSpec.describe ApplicationSetting do end end - it_behaves_like 'an object with email-formated attributes', :abuse_notification_email do + it_behaves_like 'an object with email-formatted attributes', :abuse_notification_email do subject { setting } end @@ -984,12 +984,6 @@ RSpec.describe ApplicationSetting do it_behaves_like 'application settings examples' - describe 'repository_storages_weighted_attributes' do - it 'returns the keys for repository_storages_weighted' do - expect(subject.class.repository_storages_weighted_attributes).to eq([:repository_storages_weighted_default]) - end - end - describe 'kroki_format_supported?' do it 'returns true when Excalidraw is enabled' do subject.kroki_formats_excalidraw = true @@ -1033,11 +1027,4 @@ RSpec.describe ApplicationSetting do expect(subject.kroki_formats_excalidraw).to eq(true) end end - - it 'does not allow to set weight for non existing storage' do - setting.repository_storages_weighted = { invalid_storage: 100 } - - expect(setting).not_to be_valid - expect(setting.errors.messages[:repository_storages_weighted]).to match_array(["can't include: invalid_storage"]) - end end diff --git a/spec/models/board_spec.rb b/spec/models/board_spec.rb index d309b4dbdb9..c8a9504d4fc 100644 --- a/spec/models/board_spec.rb +++ b/spec/models/board_spec.rb @@ -12,6 +12,7 @@ RSpec.describe Board do end describe 'validations' do + it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_presence_of(:project) } end diff --git a/spec/models/bulk_imports/entity_spec.rb b/spec/models/bulk_imports/entity_spec.rb index e5ab96ca514..17ab4d5954c 100644 --- a/spec/models/bulk_imports/entity_spec.rb +++ b/spec/models/bulk_imports/entity_spec.rb @@ -14,7 +14,6 @@ RSpec.describe BulkImports::Entity, type: :model do it { is_expected.to validate_presence_of(:source_type) } it { is_expected.to validate_presence_of(:source_full_path) } it { is_expected.to validate_presence_of(:destination_name) } - it { is_expected.to validate_presence_of(:destination_namespace) } it { is_expected.to define_enum_for(:source_type).with_values(%i[group_entity project_entity]) } @@ -38,7 +37,11 @@ RSpec.describe BulkImports::Entity, type: :model do context 'when associated with a group and no project' do it 'is valid as a group_entity' do entity = build(:bulk_import_entity, :group_entity, group: build(:group), project: nil) + expect(entity).to be_valid + end + it 'is valid when destination_namespace is empty' do + entity = build(:bulk_import_entity, :group_entity, group: build(:group), project: nil, destination_namespace: '') expect(entity).to be_valid end @@ -57,6 +60,12 @@ RSpec.describe BulkImports::Entity, type: :model do expect(entity).to be_valid end + it 'is invalid when destination_namespace is nil' do + entity = build(:bulk_import_entity, :group_entity, group: build(:group), project: nil, destination_namespace: nil) + expect(entity).not_to be_valid + expect(entity.errors).to include(:destination_namespace) + end + it 'is invalid as a project_entity' do entity = build(:bulk_import_entity, :group_entity, group: nil, project: build(:project)) @@ -94,7 +103,9 @@ RSpec.describe BulkImports::Entity, type: :model do ) expect(entity).not_to be_valid - expect(entity.errors).to include(:destination_namespace) + expect(entity.errors).to include(:base) + expect(entity.errors[:base]) + .to include('Import failed: Destination cannot be a subgroup of the source group. Change the destination and try again.') end it 'is invalid if destination namespace is a descendant of the source' do @@ -109,7 +120,8 @@ RSpec.describe BulkImports::Entity, type: :model do ) expect(entity).not_to be_valid - expect(entity.errors).to include(:destination_namespace) + expect(entity.errors[:base]) + .to include('Import failed: Destination cannot be a subgroup of the source group. Change the destination and try again.') end end end diff --git a/spec/models/bulk_imports/tracker_spec.rb b/spec/models/bulk_imports/tracker_spec.rb index 8eb5a6c27dd..77896105959 100644 --- a/spec/models/bulk_imports/tracker_spec.rb +++ b/spec/models/bulk_imports/tracker_spec.rb @@ -15,6 +15,8 @@ RSpec.describe BulkImports::Tracker, type: :model do it { is_expected.to validate_presence_of(:relation) } it { is_expected.to validate_uniqueness_of(:relation).scoped_to(:bulk_import_entity_id) } + it { is_expected.to validate_presence_of(:stage) } + context 'when has_next_page is true' do it "validates presence of `next_page`" do tracker = build(:bulk_import_tracker, has_next_page: true) diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb index b50e4204e0a..f3029598b02 100644 --- a/spec/models/ci/bridge_spec.rb +++ b/spec/models/ci/bridge_spec.rb @@ -41,7 +41,7 @@ RSpec.describe Ci::Bridge do end end - describe '#scoped_variables_hash' do + describe '#scoped_variables' do it 'returns a hash representing variables' do variables = %w[ CI_JOB_NAME CI_JOB_STAGE CI_COMMIT_SHA CI_COMMIT_SHORT_SHA @@ -53,7 +53,7 @@ RSpec.describe Ci::Bridge do CI_COMMIT_TIMESTAMP ] - expect(bridge.scoped_variables_hash.keys).to include(*variables) + expect(bridge.scoped_variables.map { |v| v[:key] }).to include(*variables) end context 'when bridge has dependency which has dotenv variable' do @@ -63,7 +63,7 @@ RSpec.describe Ci::Bridge do let!(:job_variable) { create(:ci_job_variable, :dotenv_source, job: test) } it 'includes inherited variable' do - expect(bridge.scoped_variables_hash).to include(job_variable.key => job_variable.value) + expect(bridge.scoped_variables.to_hash).to include(job_variable.key => job_variable.value) end end end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 4ad7ce70a44..5b07bd8923f 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -581,7 +581,7 @@ RSpec.describe Ci::Build do end it 'that cannot handle build' do - expect_any_instance_of(Ci::Runner).to receive(:can_pick?).and_return(false) + expect_any_instance_of(Ci::Runner).to receive(:matches_build?).with(build).and_return(false) is_expected.to be_falsey end end @@ -817,7 +817,48 @@ RSpec.describe Ci::Build do end describe '#cache' do - let(:options) { { cache: { key: "key", paths: ["public"], policy: "pull-push" } } } + let(:options) do + { cache: [{ key: "key", paths: ["public"], policy: "pull-push" }] } + end + + context 'with multiple_cache_per_job FF disabled' do + before do + stub_feature_flags(multiple_cache_per_job: false) + end + let(:options) { { cache: { key: "key", paths: ["public"], policy: "pull-push" } } } + + subject { build.cache } + + context 'when build has cache' do + before do + allow(build).to receive(:options).and_return(options) + end + + context 'when project has jobs_cache_index' do + before do + allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(1) + end + + it { is_expected.to be_an(Array).and all(include(key: "key-1")) } + end + + context 'when project does not have jobs_cache_index' do + before do + allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(nil) + end + + it { is_expected.to eq([options[:cache]]) } + end + end + + context 'when build does not have cache' do + before do + allow(build).to receive(:options).and_return({}) + end + + it { is_expected.to eq([]) } + end + end subject { build.cache } @@ -826,6 +867,21 @@ RSpec.describe Ci::Build do allow(build).to receive(:options).and_return(options) end + context 'when build has multiple caches' do + let(:options) do + { cache: [ + { key: "key", paths: ["public"], policy: "pull-push" }, + { key: "key2", paths: ["public"], policy: "pull-push" } + ] } + end + + before do + allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(1) + end + + it { is_expected.to match([a_hash_including(key: "key-1"), a_hash_including(key: "key2-1")]) } + end + context 'when project has jobs_cache_index' do before do allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(1) @@ -839,7 +895,7 @@ RSpec.describe Ci::Build do allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(nil) end - it { is_expected.to eq([options[:cache]]) } + it { is_expected.to eq(options[:cache]) } end end @@ -848,7 +904,7 @@ RSpec.describe Ci::Build do allow(build).to receive(:options).and_return({}) end - it { is_expected.to eq([nil]) } + it { is_expected.to be_empty } end end @@ -1205,6 +1261,21 @@ RSpec.describe Ci::Build do end end + describe '#environment_deployment_tier' do + subject { build.environment_deployment_tier } + + let(:build) { described_class.new(options: options) } + let(:options) { { environment: { deployment_tier: 'production' } } } + + it { is_expected.to eq('production') } + + context 'when options does not include deployment_tier' do + let(:options) { { environment: { name: 'production' } } } + + it { is_expected.to be_nil } + end + end + describe 'deployment' do describe '#outdated_deployment?' do subject { build.outdated_deployment? } @@ -2367,6 +2438,7 @@ RSpec.describe Ci::Build do { key: 'CI_JOB_ID', value: build.id.to_s, public: true, masked: false }, { key: 'CI_JOB_URL', value: project.web_url + "/-/jobs/#{build.id}", public: true, masked: false }, { key: 'CI_JOB_TOKEN', value: 'my-token', public: false, masked: true }, + { key: 'CI_JOB_STARTED_AT', value: build.started_at&.iso8601, public: true, masked: false }, { key: 'CI_BUILD_ID', value: build.id.to_s, public: true, masked: false }, { key: 'CI_BUILD_TOKEN', value: 'my-token', public: false, masked: true }, { key: 'CI_REGISTRY_USER', value: 'gitlab-ci-token', public: true, masked: false }, @@ -2405,17 +2477,18 @@ RSpec.describe Ci::Build do { key: 'CI_PROJECT_REPOSITORY_LANGUAGES', value: project.repository_languages.map(&:name).join(',').downcase, public: true, masked: false }, { key: 'CI_DEFAULT_BRANCH', value: project.default_branch, public: true, masked: false }, { key: 'CI_PROJECT_CONFIG_PATH', value: project.ci_config_path_or_default, public: true, masked: false }, + { key: 'CI_CONFIG_PATH', value: project.ci_config_path_or_default, public: true, masked: false }, { key: 'CI_PAGES_DOMAIN', value: Gitlab.config.pages.host, public: true, masked: false }, { key: 'CI_PAGES_URL', value: project.pages_url, public: true, masked: false }, - { key: 'CI_DEPENDENCY_PROXY_SERVER', value: "#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}", public: true, masked: false }, + { key: 'CI_DEPENDENCY_PROXY_SERVER', value: Gitlab.host_with_port, public: true, masked: false }, { key: 'CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX', - value: "#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}/#{project.namespace.root_ancestor.path}#{DependencyProxy::URL_SUFFIX}", + value: "#{Gitlab.host_with_port}/#{project.namespace.root_ancestor.path.downcase}#{DependencyProxy::URL_SUFFIX}", public: true, masked: false }, { key: 'CI_API_V4_URL', value: 'http://localhost/api/v4', public: true, masked: false }, { key: 'CI_PIPELINE_IID', value: pipeline.iid.to_s, public: true, masked: false }, { key: 'CI_PIPELINE_SOURCE', value: pipeline.source, public: true, masked: false }, - { key: 'CI_CONFIG_PATH', value: pipeline.config_path, public: true, masked: false }, + { key: 'CI_PIPELINE_CREATED_AT', value: pipeline.created_at.iso8601, public: true, masked: false }, { key: 'CI_COMMIT_SHA', value: build.sha, public: true, masked: false }, { key: 'CI_COMMIT_SHORT_SHA', value: build.short_sha, public: true, masked: false }, { key: 'CI_COMMIT_BEFORE_SHA', value: build.before_sha, public: true, masked: false }, @@ -2440,7 +2513,8 @@ RSpec.describe Ci::Build do build.yaml_variables = [] end - it { is_expected.to eq(predefined_variables) } + it { is_expected.to be_instance_of(Gitlab::Ci::Variables::Collection) } + it { expect(subject.to_runner_variables).to eq(predefined_variables) } context 'when ci_job_jwt feature flag is disabled' do before do @@ -2495,7 +2569,7 @@ RSpec.describe Ci::Build do end it 'returns variables in order depending on resource hierarchy' do - is_expected.to eq( + expect(subject.to_runner_variables).to eq( [dependency_proxy_var, job_jwt_var, build_pre_var, @@ -2525,7 +2599,7 @@ RSpec.describe Ci::Build do end it 'matches explicit variables ordering' do - received_variables = subject.map { |variable| variable.fetch(:key) } + received_variables = subject.map { |variable| variable[:key] } expect(received_variables).to eq expected_variables end @@ -2584,14 +2658,14 @@ RSpec.describe Ci::Build do end shared_examples 'containing environment variables' do - it { environment_variables.each { |v| is_expected.to include(v) } } + it { is_expected.to include(*environment_variables) } end context 'when no URL was set' do it_behaves_like 'containing environment variables' it 'does not have CI_ENVIRONMENT_URL' do - keys = subject.map { |var| var[:key] } + keys = subject.pluck(:key) expect(keys).not_to include('CI_ENVIRONMENT_URL') end @@ -2618,7 +2692,7 @@ RSpec.describe Ci::Build do it_behaves_like 'containing environment variables' it 'puts $CI_ENVIRONMENT_URL in the last so all other variables are available to be used when runners are trying to expand it' do - expect(subject.last).to eq(environment_variables.last) + expect(subject.to_runner_variables.last).to eq(environment_variables.last) end end end @@ -2951,7 +3025,7 @@ RSpec.describe Ci::Build do end it 'overrides YAML variable using a pipeline variable' do - variables = subject.reverse.uniq { |variable| variable[:key] }.reverse + variables = subject.to_runner_variables.reverse.uniq { |variable| variable[:key] }.reverse expect(variables) .not_to include(key: 'MYVAR', value: 'myvar', public: true, masked: false) @@ -3248,47 +3322,6 @@ RSpec.describe Ci::Build do end end - describe '#scoped_variables_hash' do - context 'when overriding CI variables' do - before do - project.variables.create!(key: 'MY_VAR', value: 'my value 1') - pipeline.variables.create!(key: 'MY_VAR', value: 'my value 2') - end - - it 'returns a regular hash created using valid ordering' do - expect(build.scoped_variables_hash).to include('MY_VAR': 'my value 2') - expect(build.scoped_variables_hash).not_to include('MY_VAR': 'my value 1') - end - end - - context 'when overriding user-provided variables' do - let(:build) do - create(:ci_build, pipeline: pipeline, yaml_variables: [{ key: 'MY_VAR', value: 'myvar', public: true }]) - end - - before do - pipeline.variables.build(key: 'MY_VAR', value: 'pipeline value') - end - - it 'returns a hash including variable with higher precedence' do - expect(build.scoped_variables_hash).to include('MY_VAR': 'pipeline value') - expect(build.scoped_variables_hash).not_to include('MY_VAR': 'myvar') - end - end - - context 'when overriding CI instance variables' do - before do - create(:ci_instance_variable, key: 'MY_VAR', value: 'my value 1') - group.variables.create!(key: 'MY_VAR', value: 'my value 2') - end - - it 'returns a regular hash created using valid ordering' do - expect(build.scoped_variables_hash).to include('MY_VAR': 'my value 2') - expect(build.scoped_variables_hash).not_to include('MY_VAR': 'my value 1') - end - end - end - describe '#any_unmet_prerequisites?' do let(:build) { create(:ci_build, :created) } diff --git a/spec/models/ci/daily_build_group_report_result_spec.rb b/spec/models/ci/daily_build_group_report_result_spec.rb index f6e6a6a5e02..4e96ec7cecb 100644 --- a/spec/models/ci/daily_build_group_report_result_spec.rb +++ b/spec/models/ci/daily_build_group_report_result_spec.rb @@ -162,39 +162,5 @@ RSpec.describe Ci::DailyBuildGroupReportResult do end end end - - describe '.by_date' do - subject(:coverages) { described_class.by_date(start_date) } - - let!(:coverage_1) { create(:ci_daily_build_group_report_result, date: 1.week.ago) } - - context 'when project has several coverage' do - let!(:coverage_2) { create(:ci_daily_build_group_report_result, date: 2.weeks.ago) } - let(:start_date) { 1.week.ago.to_date.to_s } - - it 'returns the coverage from the start_date' do - expect(coverages).to contain_exactly(coverage_1) - end - end - - context 'when start_date is over 90 days' do - let!(:coverage_2) { create(:ci_daily_build_group_report_result, date: 90.days.ago) } - let!(:coverage_3) { create(:ci_daily_build_group_report_result, date: 91.days.ago) } - let(:start_date) { 1.year.ago.to_date.to_s } - - it 'returns the coverage in the last 90 days' do - expect(coverages).to contain_exactly(coverage_1, coverage_2) - end - end - - context 'when start_date is not a string' do - let!(:coverage_2) { create(:ci_daily_build_group_report_result, date: 90.days.ago) } - let(:start_date) { 1.week.ago } - - it 'returns the coverage in the last 90 days' do - expect(coverages).to contain_exactly(coverage_1, coverage_2) - end - end - end end end diff --git a/spec/models/ci/group_variable_spec.rb b/spec/models/ci/group_variable_spec.rb index c8eac4d8765..f0eec549da7 100644 --- a/spec/models/ci/group_variable_spec.rb +++ b/spec/models/ci/group_variable_spec.rb @@ -9,7 +9,17 @@ RSpec.describe Ci::GroupVariable do it { is_expected.to include_module(Presentable) } it { is_expected.to include_module(Ci::Maskable) } - it { is_expected.to validate_uniqueness_of(:key).scoped_to(:group_id).with_message(/\(\w+\) has already been taken/) } + it { is_expected.to include_module(HasEnvironmentScope) } + it { is_expected.to validate_uniqueness_of(:key).scoped_to([:group_id, :environment_scope]).with_message(/\(\w+\) has already been taken/) } + + describe '.by_environment_scope' do + let!(:matching_variable) { create(:ci_group_variable, environment_scope: 'production ') } + let!(:non_matching_variable) { create(:ci_group_variable, environment_scope: 'staging') } + + subject { Ci::GroupVariable.by_environment_scope('production') } + + it { is_expected.to contain_exactly(matching_variable) } + end describe '.unprotected' do subject { described_class.unprotected } diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 94943fb3644..d57a39d133f 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -8,12 +8,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do include Ci::SourcePipelineHelpers let_it_be(:user) { create(:user) } - let_it_be(:namespace) { create_default(:namespace) } - let_it_be(:project) { create_default(:project, :repository) } - - let(:pipeline) do - create(:ci_empty_pipeline, status: :created, project: project) - end + let_it_be(:namespace) { create_default(:namespace).freeze } + let_it_be(:project) { create_default(:project, :repository).freeze } it_behaves_like 'having unique enum values' @@ -53,6 +49,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe 'associations' do + let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) } + it 'has a bidirectional relationship with projects' do expect(described_class.reflect_on_association(:project).has_inverse?).to eq(:all_pipelines) expect(Project.reflect_on_association(:all_pipelines).has_inverse?).to eq(:project) @@ -82,6 +80,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#set_status' do + let(:pipeline) { build(:ci_empty_pipeline, :created) } + where(:from_status, :to_status) do from_status_names = described_class.state_machines[:status].states.map(&:name) to_status_names = from_status_names - [:created] # we never want to transition into created @@ -105,6 +105,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '.processables' do + let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) } + before do create(:ci_build, name: 'build', pipeline: pipeline) create(:ci_bridge, name: 'bridge', pipeline: pipeline) @@ -142,7 +144,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do subject { described_class.for_sha(sha) } let(:sha) { 'abc' } - let!(:pipeline) { create(:ci_pipeline, sha: 'abc') } + + let_it_be(:pipeline) { create(:ci_pipeline, sha: 'abc') } it 'returns the pipeline' do is_expected.to contain_exactly(pipeline) @@ -170,7 +173,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do subject { described_class.for_source_sha(source_sha) } let(:source_sha) { 'abc' } - let!(:pipeline) { create(:ci_pipeline, source_sha: 'abc') } + + let_it_be(:pipeline) { create(:ci_pipeline, source_sha: 'abc') } it 'returns the pipeline' do is_expected.to contain_exactly(pipeline) @@ -228,7 +232,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do subject { described_class.for_branch(branch) } let(:branch) { 'master' } - let!(:pipeline) { create(:ci_pipeline, ref: 'master') } + + let_it_be(:pipeline) { create(:ci_pipeline, ref: 'master') } it 'returns the pipeline' do is_expected.to contain_exactly(pipeline) @@ -247,13 +252,16 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '.ci_sources' do subject { described_class.ci_sources } - let!(:push_pipeline) { create(:ci_pipeline, source: :push) } - let!(:web_pipeline) { create(:ci_pipeline, source: :web) } - let!(:api_pipeline) { create(:ci_pipeline, source: :api) } - let!(:webide_pipeline) { create(:ci_pipeline, source: :webide) } - let!(:child_pipeline) { create(:ci_pipeline, source: :parent_pipeline) } + let(:push_pipeline) { build(:ci_pipeline, source: :push) } + let(:web_pipeline) { build(:ci_pipeline, source: :web) } + let(:api_pipeline) { build(:ci_pipeline, source: :api) } + let(:webide_pipeline) { build(:ci_pipeline, source: :webide) } + let(:child_pipeline) { build(:ci_pipeline, source: :parent_pipeline) } + let(:pipelines) { [push_pipeline, web_pipeline, api_pipeline, webide_pipeline, child_pipeline] } it 'contains pipelines having CI only sources' do + pipelines.map(&:save!) + expect(subject).to contain_exactly(push_pipeline, web_pipeline, api_pipeline) end @@ -365,8 +373,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end end - describe '#merge_request_pipeline?' do - subject { pipeline.merge_request_pipeline? } + describe '#merged_result_pipeline?' do + subject { pipeline.merged_result_pipeline? } let!(:pipeline) do create(:ci_pipeline, source: :merge_request_event, merge_request: merge_request, target_sha: target_sha) @@ -387,6 +395,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#merge_request_ref?' do subject { pipeline.merge_request_ref? } + let(:pipeline) { build(:ci_empty_pipeline, :created) } + it 'calls MergeRequest#merge_request_ref?' do expect(MergeRequest).to receive(:merge_request_ref?).with(pipeline.ref) @@ -606,7 +616,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#source' do context 'when creating new pipeline' do let(:pipeline) do - build(:ci_empty_pipeline, status: :created, project: project, source: nil) + build(:ci_empty_pipeline, :created, project: project, source: nil) end it "prevents from creating an object" do @@ -615,17 +625,21 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when updating existing pipeline' do + let(:pipeline) { create(:ci_empty_pipeline, :created) } + before do pipeline.update_attribute(:source, nil) end - it "object is valid" do + it 'object is valid' do expect(pipeline).to be_valid end end end describe '#block' do + let(:pipeline) { create(:ci_empty_pipeline, :created) } + it 'changes pipeline status to manual' do expect(pipeline.block).to be true expect(pipeline.reload).to be_manual @@ -636,7 +650,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#delay' do subject { pipeline.delay } - let(:pipeline) { build(:ci_pipeline, status: :created) } + let(:pipeline) { build(:ci_pipeline, :created) } it 'changes pipeline status to schedule' do subject @@ -646,6 +660,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#valid_commit_sha' do + let(:pipeline) { build_stubbed(:ci_empty_pipeline, :created, project: project) } + context 'commit.sha can not start with 00000000' do before do pipeline.sha = '0' * 40 @@ -659,6 +675,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#short_sha' do subject { pipeline.short_sha } + let(:pipeline) { build_stubbed(:ci_empty_pipeline, :created) } + it 'has 8 items' do expect(subject.size).to eq(8) end @@ -668,49 +686,58 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#retried' do subject { pipeline.retried } + let(:pipeline) { create(:ci_empty_pipeline, :created, project: project) } + let!(:build1) { create(:ci_build, pipeline: pipeline, name: 'deploy', retried: true) } + before do - @build1 = create(:ci_build, pipeline: pipeline, name: 'deploy', retried: true) - @build2 = create(:ci_build, pipeline: pipeline, name: 'deploy') + create(:ci_build, pipeline: pipeline, name: 'deploy') end it 'returns old builds' do - is_expected.to contain_exactly(@build1) + is_expected.to contain_exactly(build1) end end describe '#coverage' do - let(:project) { create(:project, build_coverage_regex: "/.*/") } - let(:pipeline) { create(:ci_empty_pipeline, project: project) } + let_it_be_with_reload(:pipeline) { create(:ci_empty_pipeline) } - it "calculates average when there are two builds with coverage" do - create(:ci_build, name: "rspec", coverage: 30, pipeline: pipeline) - create(:ci_build, name: "rubocop", coverage: 40, pipeline: pipeline) - expect(pipeline.coverage).to eq("35.00") - end + context 'with multiple pipelines' do + before_all do + create(:ci_build, name: "rspec", coverage: 30, pipeline: pipeline) + create(:ci_build, name: "rubocop", coverage: 40, pipeline: pipeline) + end - it "calculates average when there are two builds with coverage and one with nil" do - create(:ci_build, name: "rspec", coverage: 30, pipeline: pipeline) - create(:ci_build, name: "rubocop", coverage: 40, pipeline: pipeline) - create(:ci_build, pipeline: pipeline) - expect(pipeline.coverage).to eq("35.00") - end + it "calculates average when there are two builds with coverage" do + expect(pipeline.coverage).to eq("35.00") + end + + it "calculates average when there are two builds with coverage and one with nil" do + create(:ci_build, pipeline: pipeline) + + expect(pipeline.coverage).to eq("35.00") + end - it "calculates average when there are two builds with coverage and one is retried" do - create(:ci_build, name: "rspec", coverage: 30, pipeline: pipeline) - create(:ci_build, name: "rubocop", coverage: 30, pipeline: pipeline, retried: true) - create(:ci_build, name: "rubocop", coverage: 40, pipeline: pipeline) - expect(pipeline.coverage).to eq("35.00") + it "calculates average when there are two builds with coverage and one is retried" do + create(:ci_build, name: "rubocop", coverage: 30, pipeline: pipeline, retried: true) + + expect(pipeline.coverage).to eq("35.00") + end end - it "calculates average when there is one build without coverage" do - FactoryBot.create(:ci_build, pipeline: pipeline) - expect(pipeline.coverage).to be_nil + context 'when there is one build without coverage' do + it "calculates average to nil" do + create(:ci_build, pipeline: pipeline) + + expect(pipeline.coverage).to be_nil + end end end describe '#retryable?' do subject { pipeline.retryable? } + let_it_be(:pipeline) { create(:ci_empty_pipeline, :created, project: project) } + context 'no failed builds' do before do create_build('rspec', 'success') @@ -772,13 +799,15 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#predefined_variables' do subject { pipeline.predefined_variables } + let(:pipeline) { build(:ci_empty_pipeline, :created) } + it 'includes all predefined variables in a valid order' do keys = subject.map { |variable| variable[:key] } expect(keys).to eq %w[ CI_PIPELINE_IID CI_PIPELINE_SOURCE - CI_CONFIG_PATH + CI_PIPELINE_CREATED_AT CI_COMMIT_SHA CI_COMMIT_SHORT_SHA CI_COMMIT_BEFORE_SHA @@ -798,21 +827,18 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when merge request is present' do + let_it_be(:assignees) { create_list(:user, 2) } + let_it_be(:milestone) { create(:milestone, project: project) } + let_it_be(:labels) { create_list(:label, 2) } let(:merge_request) do - create(:merge_request, + create(:merge_request, :simple, source_project: project, - source_branch: 'feature', target_project: project, - target_branch: 'master', assignees: assignees, milestone: milestone, labels: labels) end - let(:assignees) { create_list(:user, 2) } - let(:milestone) { create(:milestone, project: project) } - let(:labels) { create_list(:label, 2) } - context 'when pipeline for merge request is created' do let(:pipeline) do create(:ci_pipeline, :detached_merge_request_pipeline, @@ -998,9 +1024,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#protected_ref?' do - before do - pipeline.project = create(:project, :repository) - end + let(:pipeline) { build(:ci_empty_pipeline, :created) } it 'delegates method to project' do expect(pipeline).not_to be_protected_ref @@ -1008,11 +1032,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#legacy_trigger' do - let(:trigger_request) { create(:ci_trigger_request) } - - before do - pipeline.trigger_requests << trigger_request - end + let(:trigger_request) { build(:ci_trigger_request) } + let(:pipeline) { build(:ci_empty_pipeline, :created, trigger_requests: [trigger_request]) } it 'returns first trigger request' do expect(pipeline.legacy_trigger).to eq trigger_request @@ -1022,6 +1043,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#auto_canceled?' do subject { pipeline.auto_canceled? } + let(:pipeline) { build(:ci_empty_pipeline, :created) } + context 'when it is canceled' do before do pipeline.cancel @@ -1029,7 +1052,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do context 'when there is auto_canceled_by' do before do - pipeline.update!(auto_canceled_by: create(:ci_empty_pipeline)) + pipeline.auto_canceled_by = create(:ci_empty_pipeline) end it 'is auto canceled' do @@ -1057,6 +1080,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe 'pipeline stages' do + let(:pipeline) { build(:ci_empty_pipeline, :created) } + describe 'legacy stages' do before do create(:commit_status, pipeline: pipeline, @@ -1107,22 +1132,28 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when commit status is retried' do - before do + let!(:old_commit_status) do create(:commit_status, pipeline: pipeline, - stage: 'build', - name: 'mac', - stage_idx: 0, - status: 'success') - - Ci::ProcessPipelineService - .new(pipeline) - .execute + stage: 'build', + name: 'mac', + stage_idx: 0, + status: 'success') end - it 'ignores the previous state' do - expect(statuses).to eq([%w(build success), - %w(test success), - %w(deploy running)]) + context 'when FF ci_remove_update_retried_from_process_pipeline is disabled' do + before do + stub_feature_flags(ci_remove_update_retried_from_process_pipeline: false) + + Ci::ProcessPipelineService + .new(pipeline) + .execute + end + + it 'ignores the previous state' do + expect(statuses).to eq([%w(build success), + %w(test success), + %w(deploy running)]) + end end end end @@ -1162,6 +1193,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#legacy_stage' do subject { pipeline.legacy_stage('test') } + let(:pipeline) { build(:ci_empty_pipeline, :created) } + context 'with status in stage' do before do create(:commit_status, pipeline: pipeline, stage: 'test') @@ -1184,6 +1217,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#stages' do + let(:pipeline) { build(:ci_empty_pipeline, :created) } + before do create(:ci_stage_entity, project: project, pipeline: pipeline, @@ -1238,6 +1273,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe 'state machine' do + let_it_be_with_reload(:pipeline) { create(:ci_empty_pipeline, :created) } let(:current) { Time.current.change(usec: 0) } let(:build) { create_build('build1', queued_at: 0) } let(:build_b) { create_build('build2', queued_at: 0) } @@ -1401,28 +1437,10 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe 'pipeline caching' do - context 'if pipeline is cacheable' do - before do - pipeline.source = 'push' - end - - it 'performs ExpirePipelinesCacheWorker' do - expect(ExpirePipelineCacheWorker).to receive(:perform_async).with(pipeline.id) + it 'performs ExpirePipelinesCacheWorker' do + expect(ExpirePipelineCacheWorker).to receive(:perform_async).with(pipeline.id) - pipeline.cancel - end - end - - context 'if pipeline is not cacheable' do - before do - pipeline.source = 'webide' - end - - it 'deos not perform ExpirePipelinesCacheWorker' do - expect(ExpirePipelineCacheWorker).not_to receive(:perform_async) - - pipeline.cancel - end + pipeline.cancel end end @@ -1441,24 +1459,25 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe 'auto merge' do - let(:merge_request) { create(:merge_request, :merge_when_pipeline_succeeds) } - - let(:pipeline) do - create(:ci_pipeline, :running, project: merge_request.source_project, - ref: merge_request.source_branch, - sha: merge_request.diff_head_sha) - end + context 'when auto merge is enabled' do + let_it_be_with_reload(:merge_request) { create(:merge_request, :merge_when_pipeline_succeeds) } + let_it_be_with_reload(:pipeline) do + create(:ci_pipeline, :running, project: merge_request.source_project, + ref: merge_request.source_branch, + sha: merge_request.diff_head_sha) + end - before do - merge_request.update_head_pipeline - end + before_all do + merge_request.update_head_pipeline + end - %w[succeed! drop! cancel! skip!].each do |action| - context "when the pipeline recieved #{action} event" do - it 'performs AutoMergeProcessWorker' do - expect(AutoMergeProcessWorker).to receive(:perform_async).with(merge_request.id) + %w[succeed! drop! cancel! skip!].each do |action| + context "when the pipeline recieved #{action} event" do + it 'performs AutoMergeProcessWorker' do + expect(AutoMergeProcessWorker).to receive(:perform_async).with(merge_request.id) - pipeline.public_send(action) + pipeline.public_send(action) + end end end end @@ -1610,15 +1629,15 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do context 'multi-project pipelines' do let!(:downstream_project) { create(:project, :repository) } - let!(:upstream_pipeline) { create(:ci_pipeline, project: project) } + let!(:upstream_pipeline) { create(:ci_pipeline) } let!(:downstream_pipeline) { create(:ci_pipeline, :with_job, project: downstream_project) } it_behaves_like 'upstream downstream pipeline' end context 'parent-child pipelines' do - let!(:upstream_pipeline) { create(:ci_pipeline, project: project) } - let!(:downstream_pipeline) { create(:ci_pipeline, :with_job, project: project) } + let!(:upstream_pipeline) { create(:ci_pipeline) } + let!(:downstream_pipeline) { create(:ci_pipeline, :with_job) } it_behaves_like 'upstream downstream pipeline' end @@ -1637,6 +1656,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#branch?' do subject { pipeline.branch? } + let(:pipeline) { build(:ci_empty_pipeline, :created) } + context 'when ref is not a tag' do before do pipeline.tag = false @@ -1647,16 +1668,12 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when pipeline is merge request' do - let(:pipeline) do - create(:ci_pipeline, merge_request: merge_request) - end + let(:pipeline) { build(:ci_pipeline, merge_request: merge_request) } let(:merge_request) do - create(:merge_request, + create(:merge_request, :simple, source_project: project, - source_branch: 'feature', - target_project: project, - target_branch: 'master') + target_project: project) end it 'returns false' do @@ -1720,6 +1737,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do context 'when repository exists' do using RSpec::Parameterized::TableSyntax + let_it_be(:pipeline, refind: true) { create(:ci_empty_pipeline) } + where(:tag, :ref, :result) do false | 'master' | true false | 'non-existent-branch' | false @@ -1728,8 +1747,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end with_them do - let(:pipeline) do - create(:ci_empty_pipeline, project: project, tag: tag, ref: ref) + before do + pipeline.update!(tag: tag, ref: ref) end it "correctly detects ref" do @@ -1739,10 +1758,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when repository does not exist' do - let(:project) { create(:project) } - let(:pipeline) do - create(:ci_empty_pipeline, project: project, ref: 'master') - end + let(:pipeline) { build(:ci_empty_pipeline, ref: 'master', project: build(:project)) } it 'always returns false' do expect(pipeline.ref_exists?).to eq false @@ -1753,7 +1769,6 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do context 'with non-empty project' do let(:pipeline) do create(:ci_pipeline, - project: project, ref: project.default_branch, sha: project.commit.sha) end @@ -1761,14 +1776,12 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#lazy_ref_commit' do let(:another) do create(:ci_pipeline, - project: project, ref: 'feature', sha: project.commit('feature').sha) end let(:unicode) do create(:ci_pipeline, - project: project, ref: 'ü/unicode/multi-byte') end @@ -1827,6 +1840,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#manual_actions' do subject { pipeline.manual_actions } + let(:pipeline) { create(:ci_empty_pipeline, :created) } + it 'when none defined' do is_expected.to be_empty end @@ -1853,9 +1868,11 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#branch_updated?' do + let(:pipeline) { create(:ci_empty_pipeline, :created) } + context 'when pipeline has before SHA' do before do - pipeline.update_column(:before_sha, 'a1b2c3d4') + pipeline.update!(before_sha: 'a1b2c3d4') end it 'runs on a branch update push' do @@ -1866,7 +1883,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do context 'when pipeline does not have before SHA' do before do - pipeline.update_column(:before_sha, Gitlab::Git::BLANK_SHA) + pipeline.update!(before_sha: Gitlab::Git::BLANK_SHA) end it 'does not run on a branch updating push' do @@ -1876,6 +1893,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#modified_paths' do + let(:pipeline) { create(:ci_empty_pipeline, :created) } + context 'when old and new revisions are set' do before do pipeline.update!(before_sha: '1234abcd', sha: '2345bcde') @@ -1892,7 +1911,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do context 'when either old or new revision is missing' do before do - pipeline.update_column(:before_sha, Gitlab::Git::BLANK_SHA) + pipeline.update!(before_sha: Gitlab::Git::BLANK_SHA) end it 'returns nil' do @@ -1906,11 +1925,9 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end let(:merge_request) do - create(:merge_request, + create(:merge_request, :simple, source_project: project, - source_branch: 'feature', - target_project: project, - target_branch: 'master') + target_project: project) end it 'returns merge request modified paths' do @@ -1944,6 +1961,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#has_kubernetes_active?' do + let(:pipeline) { create(:ci_empty_pipeline, :created, project: project) } + context 'when kubernetes is active' do context 'when user configured kubernetes from CI/CD > Clusters' do let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } @@ -1965,6 +1984,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#has_warnings?' do subject { pipeline.has_warnings? } + let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) } + context 'build which is allowed to fail fails' do before do create :ci_build, :success, pipeline: pipeline, name: 'rspec' @@ -2021,6 +2042,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#number_of_warnings' do + let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) } + it 'returns the number of warnings' do create(:ci_build, :allowed_to_fail, :failed, pipeline: pipeline, name: 'rubocop') create(:ci_bridge, :allowed_to_fail, :failed, pipeline: pipeline, name: 'rubocop') @@ -2029,7 +2052,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end it 'supports eager loading of the number of warnings' do - pipeline2 = create(:ci_empty_pipeline, status: :created, project: project) + pipeline2 = create(:ci_empty_pipeline, :created) create(:ci_build, :allowed_to_fail, :failed, pipeline: pipeline, name: 'rubocop') create(:ci_build, :allowed_to_fail, :failed, pipeline: pipeline2, name: 'rubocop') @@ -2053,6 +2076,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do subject { pipeline.needs_processing? } + let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) } + where(:processed, :result) do nil | true false | true @@ -2072,122 +2097,107 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end end - shared_context 'with some outdated pipelines' do - before do - create_pipeline(:canceled, 'ref', 'A', project) - create_pipeline(:success, 'ref', 'A', project) - create_pipeline(:failed, 'ref', 'B', project) - create_pipeline(:skipped, 'feature', 'C', project) + context 'with outdated pipelines' do + before_all do + create_pipeline(:canceled, 'ref', 'A') + create_pipeline(:success, 'ref', 'A') + create_pipeline(:failed, 'ref', 'B') + create_pipeline(:skipped, 'feature', 'C') end - def create_pipeline(status, ref, sha, project) + def create_pipeline(status, ref, sha) create( :ci_empty_pipeline, status: status, ref: ref, - sha: sha, - project: project + sha: sha ) end - end - describe '.newest_first' do - include_context 'with some outdated pipelines' - - it 'returns the pipelines from new to old' do - expect(described_class.newest_first.pluck(:status)) - .to eq(%w[skipped failed success canceled]) - end + describe '.newest_first' do + it 'returns the pipelines from new to old' do + expect(described_class.newest_first.pluck(:status)) + .to eq(%w[skipped failed success canceled]) + end - it 'searches limited backlog' do - expect(described_class.newest_first(limit: 1).pluck(:status)) - .to eq(%w[skipped]) + it 'searches limited backlog' do + expect(described_class.newest_first(limit: 1).pluck(:status)) + .to eq(%w[skipped]) + end end - end - describe '.latest_status' do - include_context 'with some outdated pipelines' - - context 'when no ref is specified' do - it 'returns the status of the latest pipeline' do - expect(described_class.latest_status).to eq('skipped') + describe '.latest_status' do + context 'when no ref is specified' do + it 'returns the status of the latest pipeline' do + expect(described_class.latest_status).to eq('skipped') + end end - end - context 'when ref is specified' do - it 'returns the status of the latest pipeline for the given ref' do - expect(described_class.latest_status('ref')).to eq('failed') + context 'when ref is specified' do + it 'returns the status of the latest pipeline for the given ref' do + expect(described_class.latest_status('ref')).to eq('failed') + end end end - end - describe '.latest_successful_for_ref' do - include_context 'with some outdated pipelines' - - let!(:latest_successful_pipeline) do - create_pipeline(:success, 'ref', 'D', project) - end + describe '.latest_successful_for_ref' do + let!(:latest_successful_pipeline) do + create_pipeline(:success, 'ref', 'D') + end - it 'returns the latest successful pipeline' do - expect(described_class.latest_successful_for_ref('ref')) - .to eq(latest_successful_pipeline) + it 'returns the latest successful pipeline' do + expect(described_class.latest_successful_for_ref('ref')) + .to eq(latest_successful_pipeline) + end end - end - describe '.latest_running_for_ref' do - include_context 'with some outdated pipelines' - - let!(:latest_running_pipeline) do - create_pipeline(:running, 'ref', 'D', project) - end + describe '.latest_running_for_ref' do + let!(:latest_running_pipeline) do + create_pipeline(:running, 'ref', 'D') + end - it 'returns the latest running pipeline' do - expect(described_class.latest_running_for_ref('ref')) - .to eq(latest_running_pipeline) + it 'returns the latest running pipeline' do + expect(described_class.latest_running_for_ref('ref')) + .to eq(latest_running_pipeline) + end end - end - - describe '.latest_failed_for_ref' do - include_context 'with some outdated pipelines' - let!(:latest_failed_pipeline) do - create_pipeline(:failed, 'ref', 'D', project) - end + describe '.latest_failed_for_ref' do + let!(:latest_failed_pipeline) do + create_pipeline(:failed, 'ref', 'D') + end - it 'returns the latest failed pipeline' do - expect(described_class.latest_failed_for_ref('ref')) - .to eq(latest_failed_pipeline) + it 'returns the latest failed pipeline' do + expect(described_class.latest_failed_for_ref('ref')) + .to eq(latest_failed_pipeline) + end end - end - - describe '.latest_successful_for_sha' do - include_context 'with some outdated pipelines' - let!(:latest_successful_pipeline) do - create_pipeline(:success, 'ref', 'awesomesha', project) - end + describe '.latest_successful_for_sha' do + let!(:latest_successful_pipeline) do + create_pipeline(:success, 'ref', 'awesomesha') + end - it 'returns the latest successful pipeline' do - expect(described_class.latest_successful_for_sha('awesomesha')) - .to eq(latest_successful_pipeline) + it 'returns the latest successful pipeline' do + expect(described_class.latest_successful_for_sha('awesomesha')) + .to eq(latest_successful_pipeline) + end end - end - - describe '.latest_successful_for_refs' do - include_context 'with some outdated pipelines' - let!(:latest_successful_pipeline1) do - create_pipeline(:success, 'ref1', 'D', project) - end + describe '.latest_successful_for_refs' do + let!(:latest_successful_pipeline1) do + create_pipeline(:success, 'ref1', 'D') + end - let!(:latest_successful_pipeline2) do - create_pipeline(:success, 'ref2', 'D', project) - end + let!(:latest_successful_pipeline2) do + create_pipeline(:success, 'ref2', 'D') + end - it 'returns the latest successful pipeline for both refs' do - refs = %w(ref1 ref2 ref3) + it 'returns the latest successful pipeline for both refs' do + refs = %w(ref1 ref2 ref3) - expect(described_class.latest_successful_for_refs(refs)).to eq({ 'ref1' => latest_successful_pipeline1, 'ref2' => latest_successful_pipeline2 }) + expect(described_class.latest_successful_for_refs(refs)).to eq({ 'ref1' => latest_successful_pipeline1, 'ref2' => latest_successful_pipeline2 }) + end end end @@ -2197,8 +2207,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do :ci_empty_pipeline, status: 'success', ref: 'master', - sha: '123', - project: project + sha: '123' ) end @@ -2207,8 +2216,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do :ci_empty_pipeline, status: 'success', ref: 'develop', - sha: '123', - project: project + sha: '123' ) end @@ -2217,8 +2225,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do :ci_empty_pipeline, status: 'success', ref: 'test', - sha: '456', - project: project + sha: '456' ) end @@ -2315,12 +2322,11 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#status', :sidekiq_inline do - let(:build) do - create(:ci_build, :created, pipeline: pipeline, name: 'test') - end - subject { pipeline.reload.status } + let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) } + let(:build) { create(:ci_build, :created, pipeline: pipeline, name: 'test') } + context 'on waiting for resource' do before do allow(build).to receive(:with_resource_group?) { true } @@ -2412,8 +2418,10 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#detailed_status' do subject { pipeline.detailed_status(user) } + let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) } + context 'when pipeline is created' do - let(:pipeline) { create(:ci_pipeline, status: :created) } + let(:pipeline) { create(:ci_pipeline, :created) } it 'returns detailed status for created pipeline' do expect(subject.text).to eq s_('CiStatusText|created') @@ -2490,6 +2498,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#cancelable?' do + let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) } + %i[created running pending].each do |status0| context "when there is a build #{status0}" do before do @@ -2581,7 +2591,9 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#cancel_running' do - let(:latest_status) { pipeline.statuses.pluck(:status) } + subject(:latest_status) { pipeline.statuses.pluck(:status) } + + let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) } context 'when there is a running external job and a regular job' do before do @@ -2624,7 +2636,9 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#retry_failed' do - let(:latest_status) { pipeline.latest_statuses.pluck(:status) } + subject(:latest_status) { pipeline.latest_statuses.pluck(:status) } + + let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) } before do stub_not_protect_default_branch @@ -2673,11 +2687,12 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#execute_hooks' do + let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) } let!(:build_a) { create_build('a', 0) } let!(:build_b) { create_build('b', 0) } let!(:hook) do - create(:project_hook, project: project, pipeline_events: enabled) + create(:project_hook, pipeline_events: enabled) end before do @@ -2703,7 +2718,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end it 'builds hook data once' do - create(:pipelines_email_service, project: project) + create(:pipelines_email_service) expect(Gitlab::DataBuilder::Pipeline).to receive(:build).once.and_call_original @@ -2789,7 +2804,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe "#merge_requests_as_head_pipeline" do - let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: 'master', sha: 'a288a022a53a5a944fae87bcec6efc87b7061808') } + let_it_be_with_reload(:pipeline) { create(:ci_empty_pipeline, status: 'created', ref: 'master', sha: 'a288a022a53a5a944fae87bcec6efc87b7061808') } it "returns merge requests whose `diff_head_sha` matches the pipeline's SHA" do allow_next_instance_of(MergeRequest) do |instance| @@ -2801,7 +2816,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end it "doesn't return merge requests whose source branch doesn't match the pipeline's ref" do - create(:merge_request, source_project: project, source_branch: 'feature', target_branch: 'master') + create(:merge_request, :simple, source_project: project) expect(pipeline.merge_requests_as_head_pipeline).to be_empty end @@ -2817,7 +2832,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#all_merge_requests' do - let(:project) { create(:project) } + let_it_be_with_reload(:project) { create(:project) } + let_it_be(:pipeline) { create(:ci_empty_pipeline, :created, project: project) } shared_examples 'a method that returns all merge requests for a given pipeline' do let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: pipeline_project, ref: 'master') } @@ -2911,10 +2927,9 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#related_merge_requests' do - let(:project) { create(:project, :repository) } let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_branch: 'master') } let(:other_merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_branch: 'stable') } - let(:branch_pipeline) { create(:ci_pipeline, project: project, ref: 'feature') } + let(:branch_pipeline) { create(:ci_pipeline, ref: 'feature') } let(:merge_pipeline) { create(:ci_pipeline, :detached_merge_request_pipeline, merge_request: merge_request) } context 'for a branch pipeline' do @@ -2951,9 +2966,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#open_merge_requests_refs' do - let(:project) { create(:project) } - let(:user) { create(:user) } - let!(:pipeline) { create(:ci_pipeline, user: user, project: project, ref: 'feature') } + let!(:pipeline) { create(:ci_pipeline, user: user, ref: 'feature') } let!(:merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_branch: 'master') } subject { pipeline.open_merge_requests_refs } @@ -3000,6 +3013,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#same_family_pipeline_ids' do subject { pipeline.same_family_pipeline_ids.map(&:id) } + let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) } + context 'when pipeline is not child nor parent' do it 'returns just the pipeline id' do expect(subject).to contain_exactly(pipeline.id) @@ -3007,7 +3022,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when pipeline is child' do - let(:parent) { create(:ci_pipeline, project: project) } + let(:parent) { create(:ci_pipeline) } let!(:pipeline) { create(:ci_pipeline, child_of: parent) } let!(:sibling) { create(:ci_pipeline, child_of: parent) } @@ -3025,7 +3040,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when pipeline is a child of a child pipeline' do - let(:ancestor) { create(:ci_pipeline, project: project) } + let(:ancestor) { create(:ci_pipeline) } let!(:parent) { create(:ci_pipeline, child_of: ancestor) } let!(:pipeline) { create(:ci_pipeline, child_of: parent) } let!(:cousin_parent) { create(:ci_pipeline, child_of: ancestor) } @@ -3050,10 +3065,10 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#root_ancestor' do subject { pipeline.root_ancestor } - let_it_be(:pipeline) { create(:ci_pipeline, project: project) } + let_it_be(:pipeline) { create(:ci_pipeline) } context 'when pipeline is child of child pipeline' do - let!(:root_ancestor) { create(:ci_pipeline, project: project) } + let!(:root_ancestor) { create(:ci_pipeline) } let!(:parent_pipeline) { create(:ci_pipeline, child_of: root_ancestor) } let!(:pipeline) { create(:ci_pipeline, child_of: parent_pipeline) } @@ -3088,6 +3103,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#stuck?' do + let(:pipeline) { create(:ci_empty_pipeline, :created) } + before do create(:ci_build, :pending, pipeline: pipeline) end @@ -3132,6 +3149,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#has_yaml_errors?' do + let(:pipeline) { build_stubbed(:ci_pipeline) } + context 'when yaml_errors is set' do before do pipeline.yaml_errors = 'File not found' @@ -3201,7 +3220,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do context 'when pipeline is not the latest' do before do - create(:ci_pipeline, :success, project: project, ci_ref: pipeline.ci_ref) + create(:ci_pipeline, :success, ci_ref: pipeline.ci_ref) end it 'does not pass ref_status' do @@ -3302,7 +3321,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#builds_in_self_and_descendants' do subject(:builds) { pipeline.builds_in_self_and_descendants } - let(:pipeline) { create(:ci_pipeline, project: project) } + let(:pipeline) { create(:ci_pipeline) } let!(:build) { create(:ci_build, pipeline: pipeline) } context 'when pipeline is standalone' do @@ -3333,6 +3352,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#build_with_artifacts_in_self_and_descendants' do + let_it_be(:pipeline) { create(:ci_pipeline) } let!(:build) { create(:ci_build, name: 'test', pipeline: pipeline) } let(:child_pipeline) { create(:ci_pipeline, child_of: pipeline) } let!(:child_build) { create(:ci_build, :artifacts, name: 'test', pipeline: child_pipeline) } @@ -3351,6 +3371,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#find_job_with_archive_artifacts' do + let(:pipeline) { create(:ci_pipeline) } let!(:old_job) { create(:ci_build, name: 'rspec', retried: true, pipeline: pipeline) } let!(:job_without_artifacts) { create(:ci_build, name: 'rspec', pipeline: pipeline) } let!(:expected_job) { create(:ci_build, :artifacts, name: 'rspec', pipeline: pipeline ) } @@ -3364,6 +3385,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#latest_builds_with_artifacts' do + let(:pipeline) { create(:ci_pipeline) } let!(:fresh_build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } let!(:stale_build) { create(:ci_build, :success, :expired, :artifacts, pipeline: pipeline) } @@ -3390,7 +3412,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#batch_lookup_report_artifact_for_file_type' do context 'with code quality report artifact' do - let(:pipeline) { create(:ci_pipeline, :with_codequality_reports, project: project) } + let(:pipeline) { create(:ci_pipeline, :with_codequality_reports) } it "returns the code quality artifact" do expect(pipeline.batch_lookup_report_artifact_for_file_type(:codequality)).to eq(pipeline.job_artifacts.sample) @@ -3399,24 +3421,26 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#latest_report_builds' do + let_it_be(:pipeline) { create(:ci_pipeline, project: project) } + it 'returns build with test artifacts' do - test_build = create(:ci_build, :test_reports, pipeline: pipeline, project: project) - coverage_build = create(:ci_build, :coverage_reports, pipeline: pipeline, project: project) + test_build = create(:ci_build, :test_reports, pipeline: pipeline) + coverage_build = create(:ci_build, :coverage_reports, pipeline: pipeline) create(:ci_build, :artifacts, pipeline: pipeline, project: project) expect(pipeline.latest_report_builds).to contain_exactly(test_build, coverage_build) end it 'filters builds by scope' do - test_build = create(:ci_build, :test_reports, pipeline: pipeline, project: project) - create(:ci_build, :coverage_reports, pipeline: pipeline, project: project) + test_build = create(:ci_build, :test_reports, pipeline: pipeline) + create(:ci_build, :coverage_reports, pipeline: pipeline) expect(pipeline.latest_report_builds(Ci::JobArtifact.test_reports)).to contain_exactly(test_build) end it 'only returns not retried builds' do - test_build = create(:ci_build, :test_reports, pipeline: pipeline, project: project) - create(:ci_build, :test_reports, :retried, pipeline: pipeline, project: project) + test_build = create(:ci_build, :test_reports, pipeline: pipeline) + create(:ci_build, :test_reports, :retried, pipeline: pipeline) expect(pipeline.latest_report_builds).to contain_exactly(test_build) end @@ -3427,17 +3451,17 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do context 'when pipeline has builds with test reports' do before do - create(:ci_build, :test_reports, pipeline: pipeline, project: project) + create(:ci_build, :test_reports, pipeline: pipeline) end context 'when pipeline status is running' do - let(:pipeline) { create(:ci_pipeline, :running, project: project) } + let(:pipeline) { create(:ci_pipeline, :running) } it { is_expected.to be_falsey } end context 'when pipeline status is success' do - let(:pipeline) { create(:ci_pipeline, :success, project: project) } + let(:pipeline) { create(:ci_pipeline, :success) } it { is_expected.to be_truthy } end @@ -3445,20 +3469,20 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do context 'when pipeline does not have builds with test reports' do before do - create(:ci_build, :artifacts, pipeline: pipeline, project: project) + create(:ci_build, :artifacts, pipeline: pipeline) end - let(:pipeline) { create(:ci_pipeline, :success, project: project) } + let(:pipeline) { create(:ci_pipeline, :success) } it { is_expected.to be_falsey } end context 'when retried build has test reports' do before do - create(:ci_build, :retried, :test_reports, pipeline: pipeline, project: project) + create(:ci_build, :retried, :test_reports, pipeline: pipeline) end - let(:pipeline) { create(:ci_pipeline, :success, project: project) } + let(:pipeline) { create(:ci_pipeline, :success) } it { is_expected.to be_falsey } end @@ -3468,13 +3492,13 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do subject { pipeline.has_coverage_reports? } context 'when pipeline has a code coverage artifact' do - let(:pipeline) { create(:ci_pipeline, :with_coverage_report_artifact, :running, project: project) } + let(:pipeline) { create(:ci_pipeline, :with_coverage_report_artifact, :running) } it { expect(subject).to be_truthy } end context 'when pipeline does not have a code coverage artifact' do - let(:pipeline) { create(:ci_pipeline, :success, project: project) } + let(:pipeline) { create(:ci_pipeline, :success) } it { expect(subject).to be_falsey } end @@ -3485,17 +3509,17 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do context 'when pipeline has builds with coverage reports' do before do - create(:ci_build, :coverage_reports, pipeline: pipeline, project: project) + create(:ci_build, :coverage_reports, pipeline: pipeline) end context 'when pipeline status is running' do - let(:pipeline) { create(:ci_pipeline, :running, project: project) } + let(:pipeline) { create(:ci_pipeline, :running) } it { expect(subject).to be_falsey } end context 'when pipeline status is success' do - let(:pipeline) { create(:ci_pipeline, :success, project: project) } + let(:pipeline) { create(:ci_pipeline, :success) } it { expect(subject).to be_truthy } end @@ -3503,10 +3527,10 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do context 'when pipeline does not have builds with coverage reports' do before do - create(:ci_build, :artifacts, pipeline: pipeline, project: project) + create(:ci_build, :artifacts, pipeline: pipeline) end - let(:pipeline) { create(:ci_pipeline, :success, project: project) } + let(:pipeline) { create(:ci_pipeline, :success) } it { expect(subject).to be_falsey } end @@ -3516,13 +3540,13 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do subject { pipeline.has_codequality_mr_diff_report? } context 'when pipeline has a codequality mr diff report' do - let(:pipeline) { create(:ci_pipeline, :with_codequality_mr_diff_report, :running, project: project) } + let(:pipeline) { create(:ci_pipeline, :with_codequality_mr_diff_report, :running) } it { expect(subject).to be_truthy } end context 'when pipeline does not have a codequality mr diff report' do - let(:pipeline) { create(:ci_pipeline, :success, project: project) } + let(:pipeline) { create(:ci_pipeline, :success) } it { expect(subject).to be_falsey } end @@ -3533,17 +3557,17 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do context 'when pipeline has builds with codequality reports' do before do - create(:ci_build, :codequality_reports, pipeline: pipeline, project: project) + create(:ci_build, :codequality_reports, pipeline: pipeline) end context 'when pipeline status is running' do - let(:pipeline) { create(:ci_pipeline, :running, project: project) } + let(:pipeline) { create(:ci_pipeline, :running) } it { expect(subject).to be_falsey } end context 'when pipeline status is success' do - let(:pipeline) { create(:ci_pipeline, :success, project: project) } + let(:pipeline) { create(:ci_pipeline, :success) } it 'can generate a codequality report' do expect(subject).to be_truthy @@ -3563,10 +3587,10 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do context 'when pipeline does not have builds with codequality reports' do before do - create(:ci_build, :artifacts, pipeline: pipeline, project: project) + create(:ci_build, :artifacts, pipeline: pipeline) end - let(:pipeline) { create(:ci_pipeline, :success, project: project) } + let(:pipeline) { create(:ci_pipeline, :success) } it { expect(subject).to be_falsey } end @@ -3575,12 +3599,12 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#test_report_summary' do subject { pipeline.test_report_summary } - context 'when pipeline has multiple builds with report results' do - let(:pipeline) { create(:ci_pipeline, :success, project: project) } + let(:pipeline) { create(:ci_pipeline, :success) } + context 'when pipeline has multiple builds with report results' do before do - create(:ci_build, :success, :report_results, name: 'rspec', pipeline: pipeline, project: project) - create(:ci_build, :success, :report_results, name: 'java', pipeline: pipeline, project: project) + create(:ci_build, :success, :report_results, name: 'rspec', pipeline: pipeline) + create(:ci_build, :success, :report_results, name: 'java', pipeline: pipeline) end it 'returns test report summary with collected data' do @@ -3598,13 +3622,15 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#test_reports' do subject { pipeline.test_reports } + let_it_be(:pipeline) { create(:ci_pipeline) } + context 'when pipeline has multiple builds with test reports' do - let!(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline, project: project) } - let!(:build_java) { create(:ci_build, :success, name: 'java', pipeline: pipeline, project: project) } + let!(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline) } + let!(:build_java) { create(:ci_build, :success, name: 'java', pipeline: pipeline) } before do - create(:ci_job_artifact, :junit, job: build_rspec, project: project) - create(:ci_job_artifact, :junit_with_ant, job: build_java, project: project) + create(:ci_job_artifact, :junit, job: build_rspec) + create(:ci_job_artifact, :junit_with_ant, job: build_java) end it 'returns test reports with collected data' do @@ -3614,8 +3640,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when builds are retried' do - let!(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline, project: project) } - let!(:build_java) { create(:ci_build, :retried, :success, name: 'java', pipeline: pipeline, project: project) } + let!(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline) } + let!(:build_java) { create(:ci_build, :retried, :success, name: 'java', pipeline: pipeline) } it 'does not take retried builds into account' do expect(subject.total_count).to be(0) @@ -3635,13 +3661,15 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#accessibility_reports' do subject { pipeline.accessibility_reports } + let_it_be(:pipeline) { create(:ci_pipeline) } + context 'when pipeline has multiple builds with accessibility reports' do - let(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline, project: project) } - let(:build_golang) { create(:ci_build, :success, name: 'golang', pipeline: pipeline, project: project) } + let(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline) } + let(:build_golang) { create(:ci_build, :success, name: 'golang', pipeline: pipeline) } before do - create(:ci_job_artifact, :accessibility, job: build_rspec, project: project) - create(:ci_job_artifact, :accessibility_without_errors, job: build_golang, project: project) + create(:ci_job_artifact, :accessibility, job: build_rspec) + create(:ci_job_artifact, :accessibility_without_errors, job: build_golang) end it 'returns accessibility report with collected data' do @@ -3652,8 +3680,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when builds are retried' do - let(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline, project: project) } - let(:build_golang) { create(:ci_build, :retried, :success, name: 'golang', pipeline: pipeline, project: project) } + let(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline) } + let(:build_golang) { create(:ci_build, :retried, :success, name: 'golang', pipeline: pipeline) } it 'returns empty urls for accessibility reports' do expect(subject.urls).to be_empty @@ -3671,13 +3699,15 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#coverage_reports' do subject { pipeline.coverage_reports } + let_it_be(:pipeline) { create(:ci_pipeline) } + context 'when pipeline has multiple builds with coverage reports' do - let!(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline, project: project) } - let!(:build_golang) { create(:ci_build, :success, name: 'golang', pipeline: pipeline, project: project) } + let!(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline) } + let!(:build_golang) { create(:ci_build, :success, name: 'golang', pipeline: pipeline) } before do - create(:ci_job_artifact, :cobertura, job: build_rspec, project: project) - create(:ci_job_artifact, :coverage_gocov_xml, job: build_golang, project: project) + create(:ci_job_artifact, :cobertura, job: build_rspec) + create(:ci_job_artifact, :coverage_gocov_xml, job: build_golang) end it 'returns coverage reports with collected data' do @@ -3689,8 +3719,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end it 'does not execute N+1 queries' do - single_build_pipeline = create(:ci_empty_pipeline, status: :created, project: project) - single_rspec = create(:ci_build, :success, name: 'rspec', pipeline: single_build_pipeline, project: project) + single_build_pipeline = create(:ci_empty_pipeline, :created) + single_rspec = create(:ci_build, :success, name: 'rspec', pipeline: single_build_pipeline) create(:ci_job_artifact, :cobertura, job: single_rspec, project: project) control = ActiveRecord::QueryRecorder.new { single_build_pipeline.coverage_reports } @@ -3699,8 +3729,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when builds are retried' do - let!(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline, project: project) } - let!(:build_golang) { create(:ci_build, :retried, :success, name: 'golang', pipeline: pipeline, project: project) } + let!(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline) } + let!(:build_golang) { create(:ci_build, :retried, :success, name: 'golang', pipeline: pipeline) } it 'does not take retried builds into account' do expect(subject.files).to eql({}) @@ -3718,13 +3748,15 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#codequality_reports' do subject(:codequality_reports) { pipeline.codequality_reports } + let_it_be(:pipeline) { create(:ci_pipeline) } + context 'when pipeline has multiple builds with codequality reports' do - let(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline, project: project) } - let(:build_golang) { create(:ci_build, :success, name: 'golang', pipeline: pipeline, project: project) } + let(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline) } + let(:build_golang) { create(:ci_build, :success, name: 'golang', pipeline: pipeline) } before do - create(:ci_job_artifact, :codequality, job: build_rspec, project: project) - create(:ci_job_artifact, :codequality_without_errors, job: build_golang, project: project) + create(:ci_job_artifact, :codequality, job: build_rspec) + create(:ci_job_artifact, :codequality_without_errors, job: build_golang) end it 'returns codequality report with collected data' do @@ -3732,8 +3764,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when builds are retried' do - let(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline, project: project) } - let(:build_golang) { create(:ci_build, :retried, :success, name: 'golang', pipeline: pipeline, project: project) } + let(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline) } + let(:build_golang) { create(:ci_build, :retried, :success, name: 'golang', pipeline: pipeline) } it 'returns a codequality reports without degradations' do expect(codequality_reports.degradations).to be_empty @@ -3749,6 +3781,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#total_size' do + let(:pipeline) { create(:ci_pipeline) } let!(:build_job1) { create(:ci_build, pipeline: pipeline, stage_idx: 0) } let!(:build_job2) { create(:ci_build, pipeline: pipeline, stage_idx: 0) } let!(:test_job_failed_and_retried) { create(:ci_build, :failed, :retried, pipeline: pipeline, stage_idx: 1) } @@ -3785,17 +3818,11 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#default_branch?' do - let(:default_branch) { 'master'} - subject { pipeline.default_branch? } - before do - allow(project).to receive(:default_branch).and_return(default_branch) - end - context 'when pipeline ref is the default branch of the project' do let(:pipeline) do - build(:ci_empty_pipeline, status: :created, project: project, ref: default_branch) + build(:ci_empty_pipeline, :created, project: project, ref: project.default_branch) end it "returns true" do @@ -3805,7 +3832,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do context 'when pipeline ref is not the default branch of the project' do let(:pipeline) do - build(:ci_empty_pipeline, status: :created, project: project, ref: 'another_branch') + build(:ci_empty_pipeline, :created, project: project, ref: 'another_branch') end it "returns false" do @@ -3815,7 +3842,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#find_stage_by_name' do - let(:pipeline) { create(:ci_pipeline) } + let_it_be(:pipeline) { create(:ci_pipeline) } let(:stage_name) { 'test' } let(:stage) do @@ -3895,10 +3922,10 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#parent_pipeline' do - let_it_be(:project) { create(:project) } + let_it_be_with_reload(:pipeline) { create(:ci_pipeline) } context 'when pipeline is triggered by a pipeline from the same project' do - let_it_be(:upstream_pipeline) { create(:ci_pipeline, project: project) } + let_it_be(:upstream_pipeline) { create(:ci_pipeline) } let_it_be(:pipeline) { create(:ci_pipeline, child_of: upstream_pipeline) } it 'returns the parent pipeline' do @@ -3911,7 +3938,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when pipeline is triggered by a pipeline from another project' do - let(:pipeline) { create(:ci_pipeline, project: project) } + let(:pipeline) { create(:ci_pipeline) } let!(:upstream_pipeline) { create(:ci_pipeline, project: create(:project), upstream_of: pipeline) } it 'returns nil' do @@ -3938,7 +3965,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#child_pipelines' do let_it_be(:project) { create(:project) } - let(:pipeline) { create(:ci_pipeline, project: project) } + let_it_be_with_reload(:pipeline) { create(:ci_pipeline, project: project) } context 'when pipeline triggered other pipelines on same project' do let(:downstream_pipeline) { create(:ci_pipeline, project: pipeline.project) } @@ -3992,6 +4019,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe 'upstream status interactions' do + let_it_be_with_reload(:pipeline) { create(:ci_pipeline, :created) } + context 'when a pipeline has an upstream status' do context 'when an upstream status is a bridge' do let(:bridge) { create(:ci_bridge, status: :pending) } @@ -4050,6 +4079,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do describe '#source_ref_path' do subject { pipeline.source_ref_path } + let(:pipeline) { create(:ci_pipeline, :created) } + context 'when pipeline is for a branch' do it { is_expected.to eq(Gitlab::Git::BRANCH_REF_PREFIX + pipeline.source_ref.to_s) } end @@ -4062,13 +4093,15 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when pipeline is for a tag' do - let(:pipeline) { create(:ci_pipeline, project: project, tag: true) } + let(:pipeline) { create(:ci_pipeline, tag: true) } it { is_expected.to eq(Gitlab::Git::TAG_REF_PREFIX + pipeline.source_ref.to_s) } end end - describe "#builds_with_coverage" do + describe '#builds_with_coverage' do + let_it_be(:pipeline) { create(:ci_pipeline, :created) } + it 'returns builds with coverage only' do rspec = create(:ci_build, name: 'rspec', coverage: 97.1, pipeline: pipeline) jest = create(:ci_build, name: 'jest', coverage: 94.1, pipeline: pipeline) @@ -4092,10 +4125,11 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#base_and_ancestors' do - let(:same_project) { false } - subject { pipeline.base_and_ancestors(same_project: same_project) } + let_it_be(:pipeline) { create(:ci_pipeline, :created) } + let(:same_project) { false } + context 'when pipeline is not child nor parent' do it 'returns just the pipeline itself' do expect(subject).to contain_exactly(pipeline) @@ -4103,8 +4137,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when pipeline is child' do - let(:parent) { create(:ci_pipeline, project: pipeline.project) } - let(:sibling) { create(:ci_pipeline, project: pipeline.project) } + let(:parent) { create(:ci_pipeline) } + let(:sibling) { create(:ci_pipeline) } before do create_source_pipeline(parent, pipeline) @@ -4117,7 +4151,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when pipeline is parent' do - let(:child) { create(:ci_pipeline, project: pipeline.project) } + let(:child) { create(:ci_pipeline) } before do create_source_pipeline(pipeline, child) @@ -4129,8 +4163,9 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when pipeline is a child of a child pipeline' do - let(:ancestor) { create(:ci_pipeline, project: pipeline.project) } - let(:parent) { create(:ci_pipeline, project: pipeline.project) } + let_it_be(:pipeline) { create(:ci_pipeline, :created) } + let(:ancestor) { create(:ci_pipeline) } + let(:parent) { create(:ci_pipeline) } before do create_source_pipeline(ancestor, parent) @@ -4143,6 +4178,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when pipeline is a triggered pipeline' do + let_it_be(:pipeline) { create(:ci_pipeline, :created) } let(:upstream) { create(:ci_pipeline, project: create(:project)) } before do @@ -4166,8 +4202,10 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe 'reset_ancestor_bridges!' do + let_it_be(:pipeline) { create(:ci_pipeline, :created) } + context 'when the pipeline is a child pipeline and the bridge is depended' do - let!(:parent_pipeline) { create(:ci_pipeline, project: project) } + let!(:parent_pipeline) { create(:ci_pipeline) } let!(:bridge) { create_bridge(parent_pipeline, pipeline, true) } it 'marks source bridge as pending' do @@ -4191,7 +4229,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end context 'when the pipeline is a child pipeline and the bridge is not depended' do - let!(:parent_pipeline) { create(:ci_pipeline, project: project) } + let!(:parent_pipeline) { create(:ci_pipeline) } let!(:bridge) { create_bridge(parent_pipeline, pipeline, false) } it 'does not touch source bridge' do @@ -4227,6 +4265,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe 'test failure history processing' do + let(:pipeline) { build(:ci_pipeline, :created) } + it 'performs the service asynchronously when the pipeline is completed' do service = double @@ -4238,21 +4278,23 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#latest_test_report_builds' do + let_it_be(:pipeline) { create(:ci_pipeline, :created) } + it 'returns pipeline builds with test report artifacts' do - test_build = create(:ci_build, :test_reports, pipeline: pipeline, project: project) + test_build = create(:ci_build, :test_reports, pipeline: pipeline) create(:ci_build, :artifacts, pipeline: pipeline, project: project) expect(pipeline.latest_test_report_builds).to contain_exactly(test_build) end it 'preloads project on each build to avoid N+1 queries' do - create(:ci_build, :test_reports, pipeline: pipeline, project: project) + create(:ci_build, :test_reports, pipeline: pipeline) control_count = ActiveRecord::QueryRecorder.new do pipeline.latest_test_report_builds.map(&:project).map(&:full_path) end - multi_build_pipeline = create(:ci_empty_pipeline, status: :created, project: project) + multi_build_pipeline = create(:ci_empty_pipeline, :created) create(:ci_build, :test_reports, pipeline: multi_build_pipeline, project: project) create(:ci_build, :test_reports, pipeline: multi_build_pipeline, project: project) @@ -4262,30 +4304,32 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe '#builds_with_failed_tests' do + let_it_be(:pipeline) { create(:ci_pipeline, :created) } + it 'returns pipeline builds with test report artifacts' do - failed_build = create(:ci_build, :failed, :test_reports, pipeline: pipeline, project: project) - create(:ci_build, :success, :test_reports, pipeline: pipeline, project: project) + failed_build = create(:ci_build, :failed, :test_reports, pipeline: pipeline) + create(:ci_build, :success, :test_reports, pipeline: pipeline) expect(pipeline.builds_with_failed_tests).to contain_exactly(failed_build) end it 'supports limiting the number of builds to fetch' do - create(:ci_build, :failed, :test_reports, pipeline: pipeline, project: project) - create(:ci_build, :failed, :test_reports, pipeline: pipeline, project: project) + create(:ci_build, :failed, :test_reports, pipeline: pipeline) + create(:ci_build, :failed, :test_reports, pipeline: pipeline) expect(pipeline.builds_with_failed_tests(limit: 1).count).to eq(1) end it 'preloads project on each build to avoid N+1 queries' do - create(:ci_build, :failed, :test_reports, pipeline: pipeline, project: project) + create(:ci_build, :failed, :test_reports, pipeline: pipeline) control_count = ActiveRecord::QueryRecorder.new do pipeline.builds_with_failed_tests.map(&:project).map(&:full_path) end - multi_build_pipeline = create(:ci_empty_pipeline, status: :created, project: project) - create(:ci_build, :failed, :test_reports, pipeline: multi_build_pipeline, project: project) - create(:ci_build, :failed, :test_reports, pipeline: multi_build_pipeline, project: project) + multi_build_pipeline = create(:ci_empty_pipeline, :created) + create(:ci_build, :failed, :test_reports, pipeline: multi_build_pipeline) + create(:ci_build, :failed, :test_reports, pipeline: multi_build_pipeline) expect { multi_build_pipeline.builds_with_failed_tests.map(&:project).map(&:full_path) } .not_to exceed_query_limit(control_count) diff --git a/spec/models/ci/processable_spec.rb b/spec/models/ci/processable_spec.rb index 6290f4aef16..0a43f785598 100644 --- a/spec/models/ci/processable_spec.rb +++ b/spec/models/ci/processable_spec.rb @@ -112,8 +112,8 @@ RSpec.describe Ci::Processable do it 'returns all needs attributes' do is_expected.to contain_exactly( - { 'artifacts' => true, 'name' => 'test1' }, - { 'artifacts' => true, 'name' => 'test2' } + { 'artifacts' => true, 'name' => 'test1', 'optional' => false }, + { 'artifacts' => true, 'name' => 'test2', 'optional' => false } ) end end diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index 3e5d068d780..ff3551d2a18 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -40,41 +40,39 @@ RSpec.describe Ci::Runner do context 'runner_type validations' do let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project) } - let(:group_runner) { create(:ci_runner, :group, groups: [group]) } - let(:project_runner) { create(:ci_runner, :project, projects: [project]) } - let(:instance_runner) { create(:ci_runner, :instance) } it 'disallows assigning group to project_type runner' do - project_runner.groups << build(:group) + project_runner = build(:ci_runner, :project, groups: [group]) expect(project_runner).not_to be_valid expect(project_runner.errors.full_messages).to include('Runner cannot have groups assigned') end it 'disallows assigning group to instance_type runner' do - instance_runner.groups << build(:group) + instance_runner = build(:ci_runner, :instance, groups: [group]) expect(instance_runner).not_to be_valid expect(instance_runner.errors.full_messages).to include('Runner cannot have groups assigned') end it 'disallows assigning project to group_type runner' do - group_runner.projects << build(:project) + group_runner = build(:ci_runner, :instance, projects: [project]) expect(group_runner).not_to be_valid expect(group_runner.errors.full_messages).to include('Runner cannot have projects assigned') end it 'disallows assigning project to instance_type runner' do - instance_runner.projects << build(:project) + instance_runner = build(:ci_runner, :instance, projects: [project]) expect(instance_runner).not_to be_valid expect(instance_runner.errors.full_messages).to include('Runner cannot have projects assigned') end it 'fails to save a group assigned to a project runner even if the runner is already saved' do - group.runners << project_runner - expect { group.save! } + project_runner = create(:ci_runner, :project, projects: [project]) + + expect { create(:group, runners: [project_runner]) } .to raise_error(ActiveRecord::RecordInvalid) end end @@ -352,6 +350,8 @@ RSpec.describe Ci::Runner do end describe '#can_pick?' do + using RSpec::Parameterized::TableSyntax + let_it_be(:pipeline) { create(:ci_pipeline) } let(:build) { create(:ci_build, pipeline: pipeline) } let(:runner_project) { build.project } @@ -365,6 +365,11 @@ RSpec.describe Ci::Runner do let(:other_project) { create(:project) } let(:other_runner) { create(:ci_runner, :project, projects: [other_project], tag_list: tag_list, run_untagged: run_untagged) } + before do + # `can_pick?` is not used outside the runners available for the project + stub_feature_flags(ci_runners_short_circuit_assignable_for: false) + end + it 'cannot handle builds' do expect(other_runner.can_pick?(build)).to be_falsey end @@ -432,9 +437,32 @@ RSpec.describe Ci::Runner do expect(runner.can_pick?(build)).to be_truthy end end + + it 'does not query for owned or instance runners' do + expect(described_class).not_to receive(:owned_or_instance_wide) + + runner.can_pick?(build) + end + + context 'when feature flag ci_runners_short_circuit_assignable_for is disabled' do + before do + stub_feature_flags(ci_runners_short_circuit_assignable_for: false) + end + + it 'does not query for owned or instance runners' do + expect(described_class).to receive(:owned_or_instance_wide).and_call_original + + runner.can_pick?(build) + end + end end context 'when runner is not shared' do + before do + # `can_pick?` is not used outside the runners available for the project + stub_feature_flags(ci_runners_short_circuit_assignable_for: false) + end + context 'when runner is assigned to a project' do it 'can handle builds' do expect(runner.can_pick?(build)).to be_truthy @@ -502,6 +530,29 @@ RSpec.describe Ci::Runner do it { is_expected.to be_falsey } end end + + context 'matches tags' do + where(:run_untagged, :runner_tags, :build_tags, :result) do + true | [] | [] | true + true | [] | ['a'] | false + true | %w[a b] | ['a'] | true + true | ['a'] | %w[a b] | false + true | ['a'] | ['a'] | true + false | ['a'] | ['a'] | true + false | ['b'] | ['a'] | false + false | %w[a b] | ['a'] | true + end + + with_them do + let(:tag_list) { runner_tags } + + before do + build.tag_list = build_tags + end + + it { is_expected.to eq(result) } + end + end end describe '#status' do @@ -844,27 +895,50 @@ RSpec.describe Ci::Runner do end describe '#pick_build!' do + let(:build) { create(:ci_build) } + let(:runner) { create(:ci_runner) } + context 'runner can pick the build' do it 'calls #tick_runner_queue' do - ci_build = build(:ci_build) - runner = build(:ci_runner) - allow(runner).to receive(:can_pick?).with(ci_build).and_return(true) - expect(runner).to receive(:tick_runner_queue) - runner.pick_build!(ci_build) + runner.pick_build!(build) end end context 'runner cannot pick the build' do - it 'does not call #tick_runner_queue' do - ci_build = build(:ci_build) - runner = build(:ci_runner) - allow(runner).to receive(:can_pick?).with(ci_build).and_return(false) + before do + build.tag_list = [:docker] + end + it 'does not call #tick_runner_queue' do expect(runner).not_to receive(:tick_runner_queue) - runner.pick_build!(ci_build) + runner.pick_build!(build) + end + end + + context 'build picking improvement enabled' do + before do + stub_feature_flags(ci_reduce_queries_when_ticking_runner_queue: true) + end + + it 'does not check if the build is assignable to a runner' do + expect(runner).not_to receive(:can_pick?) + + runner.pick_build!(build) + end + end + + context 'build picking improvement disabled' do + before do + stub_feature_flags(ci_reduce_queries_when_ticking_runner_queue: false) + end + + it 'checks if the build is assignable to a runner' do + expect(runner).to receive(:can_pick?).and_call_original + + runner.pick_build!(build) end end end diff --git a/spec/models/ci/variable_spec.rb b/spec/models/ci/variable_spec.rb index 26a7a2596af..93a24ba9157 100644 --- a/spec/models/ci/variable_spec.rb +++ b/spec/models/ci/variable_spec.rb @@ -14,6 +14,15 @@ RSpec.describe Ci::Variable do it { is_expected.to validate_uniqueness_of(:key).scoped_to(:project_id, :environment_scope).with_message(/\(\w+\) has already been taken/) } end + describe '.by_environment_scope' do + let!(:matching_variable) { create(:ci_variable, environment_scope: 'production ') } + let!(:non_matching_variable) { create(:ci_variable, environment_scope: 'staging') } + + subject { Ci::Variable.by_environment_scope('production') } + + it { is_expected.to contain_exactly(matching_variable) } + end + describe '.unprotected' do subject { described_class.unprotected } diff --git a/spec/models/clusters/agent_token_spec.rb b/spec/models/clusters/agent_token_spec.rb index 5cb84ee131a..a1b45df1970 100644 --- a/spec/models/clusters/agent_token_spec.rb +++ b/spec/models/clusters/agent_token_spec.rb @@ -3,8 +3,11 @@ require 'spec_helper' RSpec.describe Clusters::AgentToken do - it { is_expected.to belong_to(:agent).class_name('Clusters::Agent') } + it { is_expected.to belong_to(:agent).class_name('Clusters::Agent').required } it { is_expected.to belong_to(:created_by_user).class_name('User').optional } + it { is_expected.to validate_length_of(:description).is_at_most(1024) } + it { is_expected.to validate_length_of(:name).is_at_most(255) } + it { is_expected.to validate_presence_of(:name) } describe '#token' do it 'is generated on save' do diff --git a/spec/models/concerns/ci/has_variable_spec.rb b/spec/models/concerns/ci/has_variable_spec.rb index b5390281064..e917ec6b802 100644 --- a/spec/models/concerns/ci/has_variable_spec.rb +++ b/spec/models/concerns/ci/has_variable_spec.rb @@ -11,6 +11,17 @@ RSpec.describe Ci::HasVariable do it { is_expected.not_to allow_value('foo bar').for(:key) } it { is_expected.not_to allow_value('foo/bar').for(:key) } + describe 'scopes' do + describe '.by_key' do + let!(:matching_variable) { create(:ci_variable, key: 'example') } + let!(:non_matching_variable) { create(:ci_variable, key: 'other') } + + subject { Ci::Variable.by_key('example') } + + it { is_expected.to contain_exactly(matching_variable) } + end + end + describe '#key=' do context 'when the new key is nil' do it 'strips leading and trailing whitespaces' do diff --git a/spec/models/concerns/project_features_compatibility_spec.rb b/spec/models/concerns/project_features_compatibility_spec.rb index 2059e170446..62c9a041a85 100644 --- a/spec/models/concerns/project_features_compatibility_spec.rb +++ b/spec/models/concerns/project_features_compatibility_spec.rb @@ -4,8 +4,8 @@ require 'spec_helper' RSpec.describe ProjectFeaturesCompatibility do let(:project) { create(:project) } - let(:features_enabled) { %w(issues wiki builds merge_requests snippets) } - let(:features) { features_enabled + %w(repository pages operations) } + let(:features_enabled) { %w(issues wiki builds merge_requests snippets security_and_compliance) } + let(:features) { features_enabled + %w(repository pages operations container_registry) } # We had issues_enabled, snippets_enabled, builds_enabled, merge_requests_enabled and issues_enabled fields on projects table # All those fields got moved to a new table called project_feature and are now integers instead of booleans diff --git a/spec/models/custom_emoji_spec.rb b/spec/models/custom_emoji_spec.rb index 41ce480b02f..e34934d393a 100644 --- a/spec/models/custom_emoji_spec.rb +++ b/spec/models/custom_emoji_spec.rb @@ -4,8 +4,10 @@ require 'spec_helper' RSpec.describe CustomEmoji do describe 'Associations' do - it { is_expected.to belong_to(:namespace) } + it { is_expected.to belong_to(:namespace).inverse_of(:custom_emoji) } + it { is_expected.to belong_to(:creator).inverse_of(:created_custom_emoji) } it { is_expected.to have_db_column(:file) } + it { is_expected.to validate_presence_of(:creator) } it { is_expected.to validate_length_of(:name).is_at_most(36) } it { is_expected.to validate_presence_of(:name) } it { is_expected.to have_db_column(:external) } @@ -36,7 +38,7 @@ RSpec.describe CustomEmoji do new_emoji = build(:custom_emoji, name: old_emoji.name, namespace: old_emoji.namespace, group: group) expect(new_emoji).not_to be_valid - expect(new_emoji.errors.messages).to eq(name: ["has already been taken"]) + expect(new_emoji.errors.messages).to include(name: ["has already been taken"]) end it 'disallows non http and https file value' do diff --git a/spec/models/dependency_proxy/manifest_spec.rb b/spec/models/dependency_proxy/manifest_spec.rb index aa2e73356dd..4203644c003 100644 --- a/spec/models/dependency_proxy/manifest_spec.rb +++ b/spec/models/dependency_proxy/manifest_spec.rb @@ -29,24 +29,32 @@ RSpec.describe DependencyProxy::Manifest, type: :model do end end - describe '.find_or_initialize_by_file_name' do - subject { DependencyProxy::Manifest.find_or_initialize_by_file_name(file_name) } + describe '.find_or_initialize_by_file_name_or_digest' do + let_it_be(:file_name) { 'foo' } + let_it_be(:digest) { 'bar' } - context 'no manifest exists' do - let_it_be(:file_name) { 'foo' } + subject { DependencyProxy::Manifest.find_or_initialize_by_file_name_or_digest(file_name: file_name, digest: digest) } + context 'no manifest exists' do it 'initializes a manifest' do - expect(DependencyProxy::Manifest).to receive(:new).with(file_name: file_name) + expect(DependencyProxy::Manifest).to receive(:new).with(file_name: file_name, digest: digest) subject end end - context 'manifest exists' do + context 'manifest exists and matches file_name' do let_it_be(:dependency_proxy_manifest) { create(:dependency_proxy_manifest) } let_it_be(:file_name) { dependency_proxy_manifest.file_name } it { is_expected.to eq(dependency_proxy_manifest) } end + + context 'manifest exists and matches digest' do + let_it_be(:dependency_proxy_manifest) { create(:dependency_proxy_manifest) } + let_it_be(:digest) { dependency_proxy_manifest.digest } + + it { is_expected.to eq(dependency_proxy_manifest) } + end end end diff --git a/spec/models/email_spec.rb b/spec/models/email_spec.rb index ffdc621dd4c..62f2a53ab3c 100644 --- a/spec/models/email_spec.rb +++ b/spec/models/email_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Email do end describe 'validations' do - it_behaves_like 'an object with RFC3696 compliant email-formated attributes', :email do + it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :email do subject { build(:email) } end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 0c7d8e2969d..e021a6cf6d3 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -34,6 +34,27 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do it { is_expected.to validate_length_of(:external_url).is_at_most(255) } + describe '.before_save' do + it 'ensures environment tier when a new object is created' do + environment = build(:environment, name: 'gprd', tier: nil) + + expect { environment.save }.to change { environment.tier }.from(nil).to('production') + end + + it 'ensures environment tier when an existing object is updated' do + environment = create(:environment, name: 'gprd') + environment.update_column(:tier, nil) + + expect { environment.stop! }.to change { environment.reload.tier }.from(nil).to('production') + end + + it 'does not overwrite the existing environment tier' do + environment = create(:environment, name: 'gprd', tier: :production) + + expect { environment.update!(name: 'gstg') }.not_to change { environment.reload.tier } + end + end + describe '.order_by_last_deployed_at' do let!(:environment1) { create(:environment, project: project) } let!(:environment2) { create(:environment, project: project) } @@ -51,6 +72,62 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do end end + describe ".stopped_review_apps" do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:old_stopped_review_env) { create(:environment, :with_review_app, :stopped, created_at: 31.days.ago, project: project) } + let_it_be(:new_stopped_review_env) { create(:environment, :with_review_app, :stopped, project: project) } + let_it_be(:old_active_review_env) { create(:environment, :with_review_app, :available, created_at: 31.days.ago, project: project) } + let_it_be(:old_stopped_other_env) { create(:environment, :stopped, created_at: 31.days.ago, project: project) } + let_it_be(:new_stopped_other_env) { create(:environment, :stopped, project: project) } + let_it_be(:old_active_other_env) { create(:environment, :available, created_at: 31.days.ago, project: project) } + + let(:before) { 30.days.ago } + let(:limit) { 1000 } + + subject { project.environments.stopped_review_apps(before, limit) } # rubocop: disable RSpec/SingleLineHook + + it { is_expected.to contain_exactly(old_stopped_review_env) } + + context "current timestamp" do + let(:before) { Time.zone.now } + + it { is_expected.to contain_exactly(old_stopped_review_env, new_stopped_review_env) } + end + end + + describe "scheduled deletion" do + let_it_be(:deletable_environment) { create(:environment, auto_delete_at: Time.zone.now) } + let_it_be(:undeletable_environment) { create(:environment, auto_delete_at: nil) } + + describe ".scheduled_for_deletion" do + subject { described_class.scheduled_for_deletion } + + it { is_expected.to contain_exactly(deletable_environment) } + end + + describe ".not_scheduled_for_deletion" do + subject { described_class.not_scheduled_for_deletion } + + it { is_expected.to contain_exactly(undeletable_environment) } + end + + describe ".schedule_to_delete" do + subject { described_class.for_id(deletable_environment).schedule_to_delete } + + it "schedules the record for deletion" do + freeze_time do + subject + + deletable_environment.reload + undeletable_environment.reload + + expect(deletable_environment.auto_delete_at).to eq(1.week.from_now) + expect(undeletable_environment.auto_delete_at).to be_nil + end + end + end + end + describe 'state machine' do it 'invalidates the cache after a change' do expect(environment).to receive(:expire_etag_cache) @@ -195,6 +272,62 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do end end + describe '.for_tier' do + let_it_be(:environment) { create(:environment, :production) } + + it 'returns the production environment when searching for production tier' do + expect(described_class.for_tier(:production)).to eq([environment]) + end + + it 'returns nothing when searching for staging tier' do + expect(described_class.for_tier(:staging)).to be_empty + end + end + + describe '#guess_tier' do + using RSpec::Parameterized::TableSyntax + + subject { environment.send(:guess_tier) } + + let(:environment) { build(:environment, name: name) } + + where(:name, :tier) do + 'review/feature' | described_class.tiers[:development] + 'review/product' | described_class.tiers[:development] + 'DEV' | described_class.tiers[:development] + 'development' | described_class.tiers[:development] + 'trunk' | described_class.tiers[:development] + 'test' | described_class.tiers[:testing] + 'TEST' | described_class.tiers[:testing] + 'testing' | described_class.tiers[:testing] + 'testing-prd' | described_class.tiers[:testing] + 'acceptance-testing' | described_class.tiers[:testing] + 'QC' | described_class.tiers[:testing] + 'gstg' | described_class.tiers[:staging] + 'staging' | described_class.tiers[:staging] + 'stage' | described_class.tiers[:staging] + 'Model' | described_class.tiers[:staging] + 'MODL' | described_class.tiers[:staging] + 'Pre-production' | described_class.tiers[:staging] + 'pre' | described_class.tiers[:staging] + 'Demo' | described_class.tiers[:staging] + 'gprd' | described_class.tiers[:production] + 'gprd-cny' | described_class.tiers[:production] + 'production' | described_class.tiers[:production] + 'Production' | described_class.tiers[:production] + 'prod' | described_class.tiers[:production] + 'PROD' | described_class.tiers[:production] + 'Live' | described_class.tiers[:production] + 'canary' | described_class.tiers[:other] + 'other' | described_class.tiers[:other] + 'EXP' | described_class.tiers[:other] + end + + with_them do + it { is_expected.to eq(tier) } + end + end + describe '#expire_etag_cache' do let(:store) { Gitlab::EtagCaching::Store.new } diff --git a/spec/models/error_tracking/project_error_tracking_setting_spec.rb b/spec/models/error_tracking/project_error_tracking_setting_spec.rb index 72ed11f6c74..3ae0666f7d0 100644 --- a/spec/models/error_tracking/project_error_tracking_setting_spec.rb +++ b/spec/models/error_tracking/project_error_tracking_setting_spec.rb @@ -111,7 +111,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do describe '#sentry_client' do it 'returns sentry client' do - expect(subject.sentry_client).to be_a(Sentry::Client) + expect(subject.sentry_client).to be_a(ErrorTracking::SentryClient) end end @@ -152,7 +152,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do end end - context 'when sentry client raises Sentry::Client::Error' do + context 'when sentry client raises ErrorTracking::SentryClient::Error' do let(:sentry_client) { spy(:sentry_client) } before do @@ -160,7 +160,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do allow(subject).to receive(:sentry_client).and_return(sentry_client) allow(sentry_client).to receive(:list_issues).with(opts) - .and_raise(Sentry::Client::Error, 'error message') + .and_raise(ErrorTracking::SentryClient::Error, 'error message') end it 'returns error' do @@ -171,7 +171,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do end end - context 'when sentry client raises Sentry::Client::MissingKeysError' do + context 'when sentry client raises ErrorTracking::SentryClient::MissingKeysError' do let(:sentry_client) { spy(:sentry_client) } before do @@ -179,7 +179,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do allow(subject).to receive(:sentry_client).and_return(sentry_client) allow(sentry_client).to receive(:list_issues).with(opts) - .and_raise(Sentry::Client::MissingKeysError, 'Sentry API response is missing keys. key not found: "id"') + .and_raise(ErrorTracking::SentryClient::MissingKeysError, 'Sentry API response is missing keys. key not found: "id"') end it 'returns error' do @@ -190,7 +190,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do end end - context 'when sentry client raises Sentry::Client::ResponseInvalidSizeError' do + context 'when sentry client raises ErrorTracking::SentryClient::ResponseInvalidSizeError' do let(:sentry_client) { spy(:sentry_client) } let(:error_msg) {"Sentry API response is too big. Limit is #{Gitlab::Utils::DeepSize.human_default_max_size}."} @@ -199,7 +199,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do allow(subject).to receive(:sentry_client).and_return(sentry_client) allow(sentry_client).to receive(:list_issues).with(opts) - .and_raise(Sentry::Client::ResponseInvalidSizeError, error_msg) + .and_raise(ErrorTracking::SentryClient::ResponseInvalidSizeError, error_msg) end it 'returns error' do diff --git a/spec/models/experiment_spec.rb b/spec/models/experiment_spec.rb index 22bbf2df8fd..09dd1766acc 100644 --- a/spec/models/experiment_spec.rb +++ b/spec/models/experiment_spec.rb @@ -98,10 +98,11 @@ RSpec.describe Experiment do describe '.record_conversion_event' do let_it_be(:user) { build(:user) } + let_it_be(:context) { { a: 42 } } let(:experiment_key) { :test_experiment } - subject(:record_conversion_event) { described_class.record_conversion_event(experiment_key, user) } + subject(:record_conversion_event) { described_class.record_conversion_event(experiment_key, user, context) } context 'when no matching experiment exists' do it 'creates the experiment and uses it' do @@ -127,22 +128,79 @@ RSpec.describe Experiment do it 'sends record_conversion_event_for_user to the experiment instance' do expect_next_found_instance_of(described_class) do |experiment| - expect(experiment).to receive(:record_conversion_event_for_user).with(user) + expect(experiment).to receive(:record_conversion_event_for_user).with(user, context) end record_conversion_event end end end + shared_examples 'experiment user with context' do + let_it_be(:context) { { a: 42, 'b' => 34, 'c': { c1: 100, c2: 'c2', e: :e }, d: [1, 3] } } + let_it_be(:initial_expected_context) { { 'a' => 42, 'b' => 34, 'c' => { 'c1' => 100, 'c2' => 'c2', 'e' => 'e' }, 'd' => [1, 3] } } + + before do + subject + experiment.record_user_and_group(user, :experimental, {}) + end + + it 'has an initial context with stringified keys' do + expect(ExperimentUser.last.context).to eq(initial_expected_context) + end + + context 'when updated' do + before do + subject + experiment.record_user_and_group(user, :experimental, new_context) + end + + context 'with an empty context' do + let_it_be(:new_context) { {} } + + it 'keeps the initial context' do + expect(ExperimentUser.last.context).to eq(initial_expected_context) + end + end + + context 'with string keys' do + let_it_be(:new_context) { { f: :some_symbol } } + + it 'adds new symbols stringified' do + expected_context = initial_expected_context.merge('f' => 'some_symbol') + expect(ExperimentUser.last.context).to eq(expected_context) + end + end + + context 'with atomic values or array values' do + let_it_be(:new_context) { { b: 97, d: [99] } } + + it 'overrides the values' do + expected_context = { 'a' => 42, 'b' => 97, 'c' => { 'c1' => 100, 'c2' => 'c2', 'e' => 'e' }, 'd' => [99] } + expect(ExperimentUser.last.context).to eq(expected_context) + end + end + + context 'with nested hashes' do + let_it_be(:new_context) { { c: { g: 107 } } } + + it 'inserts nested additional values in the same keys' do + expected_context = initial_expected_context.deep_merge('c' => { 'g' => 107 }) + expect(ExperimentUser.last.context).to eq(expected_context) + end + end + end + end + describe '#record_conversion_event_for_user' do let_it_be(:user) { create(:user) } let_it_be(:experiment) { create(:experiment) } + let_it_be(:context) { { a: 42 } } - subject(:record_conversion_event_for_user) { experiment.record_conversion_event_for_user(user) } + subject { experiment.record_conversion_event_for_user(user, context) } context 'when no existing experiment_user record exists for the given user' do it 'does not update or create an experiment_user record' do - expect { record_conversion_event_for_user }.not_to change { ExperimentUser.all.to_a } + expect { subject }.not_to change { ExperimentUser.all.to_a } end end @@ -151,7 +209,13 @@ RSpec.describe Experiment do let!(:experiment_user) { create(:experiment_user, experiment: experiment, user: user, converted_at: 2.days.ago) } it 'does not update the converted_at value' do - expect { record_conversion_event_for_user }.not_to change { experiment_user.converted_at } + expect { subject }.not_to change { experiment_user.converted_at } + end + + it_behaves_like 'experiment user with context' do + before do + experiment.record_user_and_group(user, :experimental, context) + end end end @@ -159,7 +223,13 @@ RSpec.describe Experiment do let(:experiment_user) { create(:experiment_user, experiment: experiment, user: user) } it 'updates the converted_at value' do - expect { record_conversion_event_for_user }.to change { experiment_user.reload.converted_at } + expect { subject }.to change { experiment_user.reload.converted_at } + end + + it_behaves_like 'experiment user with context' do + before do + experiment.record_user_and_group(user, :experimental, context) + end end end end @@ -196,24 +266,25 @@ RSpec.describe Experiment do describe '#record_user_and_group' do let_it_be(:experiment) { create(:experiment) } let_it_be(:user) { create(:user) } + let_it_be(:group) { :control } + let_it_be(:context) { { a: 42 } } - let(:group) { :control } - let(:context) { { a: 42 } } - - subject(:record_user_and_group) { experiment.record_user_and_group(user, group, context) } + subject { experiment.record_user_and_group(user, group, context) } context 'when an experiment_user does not yet exist for the given user' do it 'creates a new experiment_user record' do - expect { record_user_and_group }.to change(ExperimentUser, :count).by(1) + expect { subject }.to change(ExperimentUser, :count).by(1) end it 'assigns the correct group_type to the experiment_user' do - record_user_and_group + subject + expect(ExperimentUser.last.group_type).to eq('control') end it 'adds the correct context to the experiment_user' do - record_user_and_group + subject + expect(ExperimentUser.last.context).to eq({ 'a' => 42 }) end end @@ -225,72 +296,18 @@ RSpec.describe Experiment do end it 'does not create a new experiment_user record' do - expect { record_user_and_group }.not_to change(ExperimentUser, :count) + expect { subject }.not_to change(ExperimentUser, :count) end context 'but the group_type and context has changed' do let(:group) { :experimental } it 'updates the existing experiment_user record with group_type' do - expect { record_user_and_group }.to change { ExperimentUser.last.group_type } + expect { subject }.to change { ExperimentUser.last.group_type } end end - end - - context 'when a context already exists' do - let_it_be(:context) { { a: 42, 'b' => 34, 'c': { c1: 100, c2: 'c2', e: :e }, d: [1, 3] } } - let_it_be(:initial_expected_context) { { 'a' => 42, 'b' => 34, 'c' => { 'c1' => 100, 'c2' => 'c2', 'e' => 'e' }, 'd' => [1, 3] } } - - before do - record_user_and_group - experiment.record_user_and_group(user, :control, {}) - end - - it 'has an initial context with stringified keys' do - expect(ExperimentUser.last.context).to eq(initial_expected_context) - end - - context 'when updated' do - before do - record_user_and_group - experiment.record_user_and_group(user, :control, new_context) - end - - context 'with an empty context' do - let_it_be(:new_context) { {} } - it 'keeps the initial context' do - expect(ExperimentUser.last.context).to eq(initial_expected_context) - end - end - - context 'with string keys' do - let_it_be(:new_context) { { f: :some_symbol } } - - it 'adds new symbols stringified' do - expected_context = initial_expected_context.merge('f' => 'some_symbol') - expect(ExperimentUser.last.context).to eq(expected_context) - end - end - - context 'with atomic values or array values' do - let_it_be(:new_context) { { b: 97, d: [99] } } - - it 'overrides the values' do - expected_context = { 'a' => 42, 'b' => 97, 'c' => { 'c1' => 100, 'c2' => 'c2', 'e' => 'e' }, 'd' => [99] } - expect(ExperimentUser.last.context).to eq(expected_context) - end - end - - context 'with nested hashes' do - let_it_be(:new_context) { { c: { g: 107 } } } - - it 'inserts nested additional values in the same keys' do - expected_context = initial_expected_context.deep_merge('c' => { 'g' => 107 }) - expect(ExperimentUser.last.context).to eq(expected_context) - end - end - end + it_behaves_like 'experiment user with context' end end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index e79b54b4674..24d09d1c035 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -25,7 +25,6 @@ RSpec.describe Group do it { is_expected.to have_many(:clusters).class_name('Clusters::Cluster') } it { is_expected.to have_many(:container_repositories) } it { is_expected.to have_many(:milestones) } - it { is_expected.to have_many(:iterations) } it { is_expected.to have_many(:group_deploy_keys) } it { is_expected.to have_many(:services) } it { is_expected.to have_one(:dependency_proxy_setting) } @@ -65,6 +64,59 @@ RSpec.describe Group do it { is_expected.to validate_presence_of :two_factor_grace_period } it { is_expected.to validate_numericality_of(:two_factor_grace_period).is_greater_than_or_equal_to(0) } + context 'validating the parent of a group' do + context 'when the group has no parent' do + it 'allows a group to have no parent associated with it' do + group = build(:group) + + expect(group).to be_valid + end + end + + context 'when the group has a parent' do + it 'does not allow a group to have a namespace as its parent' do + group = build(:group, parent: build(:namespace)) + + expect(group).not_to be_valid + expect(group.errors[:parent_id].first).to eq('a group cannot have a user namespace as its parent') + end + + it 'allows a group to have another group as its parent' do + group = build(:group, parent: build(:group)) + + expect(group).to be_valid + end + end + + context 'when the feature flag `validate_namespace_parent_type` is disabled' do + before do + stub_feature_flags(validate_namespace_parent_type: false) + end + + context 'when the group has no parent' do + it 'allows a group to have no parent associated with it' do + group = build(:group) + + expect(group).to be_valid + end + end + + context 'when the group has a parent' do + it 'allows a group to have a namespace as its parent' do + group = build(:group, parent: build(:namespace)) + + expect(group).to be_valid + end + + it 'allows a group to have another group as its parent' do + group = build(:group, parent: build(:group)) + + expect(group).to be_valid + end + end + end + end + describe 'path validation' do it 'rejects paths reserved on the root namespace when the group has no parent' do group = build(:group, path: 'api') @@ -513,6 +565,42 @@ RSpec.describe Group do end end + describe '#last_blocked_owner?' do + let(:blocked_user) { create(:user, :blocked) } + + before do + group.add_user(blocked_user, GroupMember::OWNER) + end + + it { expect(group.last_blocked_owner?(blocked_user)).to be_truthy } + + context 'with another active owner' do + before do + group.add_user(create(:user), GroupMember::OWNER) + end + + it { expect(group.last_blocked_owner?(blocked_user)).to be_falsy } + end + + context 'with 2 blocked owners' do + before do + group.add_user(create(:user, :blocked), GroupMember::OWNER) + end + + it { expect(group.last_blocked_owner?(blocked_user)).to be_falsy } + end + + context 'with owners from a parent' do + before do + parent_group = create(:group) + create(:group_member, :owner, group: parent_group) + group.update(parent: parent_group) + end + + it { expect(group.last_blocked_owner?(blocked_user)).to be_falsy } + end + end + describe '#lfs_enabled?' do context 'LFS enabled globally' do before do @@ -729,8 +817,16 @@ RSpec.describe Group do context 'evaluating admin access level' do let_it_be(:admin) { create(:admin) } - it 'returns OWNER by default' do - expect(group.max_member_access_for_user(admin)).to eq(Gitlab::Access::OWNER) + context 'when admin mode is enabled', :enable_admin_mode do + it 'returns OWNER by default' do + expect(group.max_member_access_for_user(admin)).to eq(Gitlab::Access::OWNER) + end + end + + context 'when admin mode is disabled' do + it 'returns NO_ACCESS' do + expect(group.max_member_access_for_user(admin)).to eq(Gitlab::Access::NO_ACCESS) + end end it 'returns NO_ACCESS when only concrete membership should be considered' do @@ -740,6 +836,33 @@ RSpec.describe Group do end end + describe '#direct_members' do + let_it_be(:group) { create(:group, :nested) } + let_it_be(:maintainer) { group.parent.add_user(create(:user), GroupMember::MAINTAINER) } + let_it_be(:developer) { group.add_user(create(:user), GroupMember::DEVELOPER) } + + it 'does not return members of the parent' do + expect(group.direct_members).not_to include(maintainer) + end + + it 'returns the direct member of the group' do + expect(group.direct_members).to include(developer) + end + + context 'group sharing' do + let!(:shared_group) { create(:group) } + + before do + create(:group_group_link, shared_group: shared_group, shared_with_group: group) + end + + it 'does not return members of the shared_with group' do + expect(shared_group.direct_members).not_to( + include(developer)) + end + end + end + describe '#members_with_parents' do let!(:group) { create(:group, :nested) } let!(:maintainer) { group.parent.add_user(create(:user), GroupMember::MAINTAINER) } @@ -932,6 +1055,65 @@ RSpec.describe Group do end end + describe '#refresh_members_authorized_projects' do + let_it_be(:group) { create(:group, :nested) } + let_it_be(:parent_group_user) { create(:user) } + let_it_be(:group_user) { create(:user) } + + before do + group.parent.add_maintainer(parent_group_user) + group.add_developer(group_user) + end + + context 'users for which authorizations refresh is executed' do + it 'processes authorizations refresh for all members of the group' do + expect(UserProjectAccessChangedService).to receive(:new).with(contain_exactly(group_user.id, parent_group_user.id)).and_call_original + + group.refresh_members_authorized_projects + end + + context 'when explicitly specified to run only for direct members' do + it 'processes authorizations refresh only for direct members of the group' do + expect(UserProjectAccessChangedService).to receive(:new).with(contain_exactly(group_user.id)).and_call_original + + group.refresh_members_authorized_projects(direct_members_only: true) + end + end + end + end + + describe '#users_ids_of_direct_members' do + let_it_be(:group) { create(:group, :nested) } + let_it_be(:parent_group_user) { create(:user) } + let_it_be(:group_user) { create(:user) } + + before do + group.parent.add_maintainer(parent_group_user) + group.add_developer(group_user) + end + + it 'does not return user ids of the members of the parent' do + expect(group.users_ids_of_direct_members).not_to include(parent_group_user.id) + end + + it 'returns the user ids of the direct member of the group' do + expect(group.users_ids_of_direct_members).to include(group_user.id) + end + + context 'group sharing' do + let!(:shared_group) { create(:group) } + + before do + create(:group_group_link, shared_group: shared_group, shared_with_group: group) + end + + it 'does not return the user ids of members of the shared_with group' do + expect(shared_group.users_ids_of_direct_members).not_to( + include(group_user.id)) + end + end + end + describe '#user_ids_for_project_authorizations' do it 'returns the user IDs for which to refresh authorizations' do maintainer = create(:user) @@ -959,6 +1141,29 @@ RSpec.describe Group do include(group_user.id)) end end + + context 'distinct user ids' do + let_it_be(:subgroup) { create(:group, :nested) } + let_it_be(:user) { create(:user) } + let_it_be(:shared_with_group) { create(:group) } + let_it_be(:other_subgroup_user) { create(:user) } + + before do + create(:group_group_link, shared_group: subgroup, shared_with_group: shared_with_group) + subgroup.add_maintainer(other_subgroup_user) + + # `user` is added as a direct member of the parent group, the subgroup + # and another group shared with the subgroup. + subgroup.parent.add_maintainer(user) + subgroup.add_developer(user) + shared_with_group.add_guest(user) + end + + it 'returns only distinct user ids of users for which to refresh authorizations' do + expect(subgroup.user_ids_for_project_authorizations).to( + contain_exactly(user.id, other_subgroup_user.id)) + end + end end describe '#update_two_factor_requirement' do @@ -1149,9 +1354,10 @@ RSpec.describe Group do describe '#ci_variables_for' do let(:project) { create(:project, group: group) } + let(:environment_scope) { '*' } let!(:ci_variable) do - create(:ci_group_variable, value: 'secret', group: group) + create(:ci_group_variable, value: 'secret', group: group, environment_scope: environment_scope) end let!(:protected_variable) do @@ -1160,13 +1366,16 @@ RSpec.describe Group do subject { group.ci_variables_for('ref', project) } - it 'memoizes the result by ref', :request_store do + it 'memoizes the result by ref and environment', :request_store do + scoped_variable = create(:ci_group_variable, value: 'secret', group: group, environment_scope: 'scoped') + expect(project).to receive(:protected_for?).with('ref').once.and_return(true) - expect(project).to receive(:protected_for?).with('other').once.and_return(false) + expect(project).to receive(:protected_for?).with('other').twice.and_return(false) 2.times do - expect(group.ci_variables_for('ref', project)).to contain_exactly(ci_variable, protected_variable) + expect(group.ci_variables_for('ref', project, environment: 'production')).to contain_exactly(ci_variable, protected_variable) expect(group.ci_variables_for('other', project)).to contain_exactly(ci_variable) + expect(group.ci_variables_for('other', project, environment: 'scoped')).to contain_exactly(ci_variable, scoped_variable) end end @@ -1203,6 +1412,120 @@ RSpec.describe Group do it_behaves_like 'ref is protected' end + context 'when environment name is specified' do + let(:environment) { 'review/name' } + + subject do + group.ci_variables_for('ref', project, environment: environment) + end + + context 'when environment scope is exactly matched' do + let(:environment_scope) { 'review/name' } + + it { is_expected.to contain_exactly(ci_variable) } + end + + context 'when environment scope is matched by wildcard' do + let(:environment_scope) { 'review/*' } + + it { is_expected.to contain_exactly(ci_variable) } + end + + context 'when environment scope does not match' do + let(:environment_scope) { 'review/*/special' } + + it { is_expected.not_to contain_exactly(ci_variable) } + end + + context 'when environment scope has _' do + let(:environment_scope) { '*_*' } + + it 'does not treat it as wildcard' do + is_expected.not_to contain_exactly(ci_variable) + end + + context 'when environment name contains underscore' do + let(:environment) { 'foo_bar/test' } + let(:environment_scope) { 'foo_bar/*' } + + it 'matches literally for _' do + is_expected.to contain_exactly(ci_variable) + end + end + end + + # The environment name and scope cannot have % at the moment, + # but we're considering relaxing it and we should also make sure + # it doesn't break in case some data sneaked in somehow as we're + # not checking this integrity in database level. + context 'when environment scope has %' do + it 'does not treat it as wildcard' do + ci_variable.update_attribute(:environment_scope, '*%*') + + is_expected.not_to contain_exactly(ci_variable) + end + + context 'when environment name contains a percent' do + let(:environment) { 'foo%bar/test' } + + it 'matches literally for %' do + ci_variable.update(environment_scope: 'foo%bar/*') + + is_expected.to contain_exactly(ci_variable) + end + end + end + + context 'when variables with the same name have different environment scopes' do + let!(:partially_matched_variable) do + create(:ci_group_variable, + key: ci_variable.key, + value: 'partial', + environment_scope: 'review/*', + group: group) + end + + let!(:perfectly_matched_variable) do + create(:ci_group_variable, + key: ci_variable.key, + value: 'prefect', + environment_scope: 'review/name', + group: group) + end + + it 'puts variables matching environment scope more in the end' do + is_expected.to eq( + [ci_variable, + partially_matched_variable, + perfectly_matched_variable]) + end + end + + context 'when :scoped_group_variables feature flag is disabled' do + before do + stub_feature_flags(scoped_group_variables: false) + end + + context 'when environment scope is exactly matched' do + let(:environment_scope) { 'review/name' } + + it { is_expected.to contain_exactly(ci_variable) } + end + + context 'when environment scope is partially matched' do + let(:environment_scope) { 'review/*' } + + it { is_expected.to contain_exactly(ci_variable) } + end + + context 'when environment scope does not match' do + let(:environment_scope) { 'review/*/special' } + + it { is_expected.to contain_exactly(ci_variable) } + end + end + end + context 'when group has children' do let(:group_child) { create(:group, parent: group) } let(:group_child_2) { create(:group, parent: group_child) } diff --git a/spec/models/issue_email_participant_spec.rb b/spec/models/issue_email_participant_spec.rb index f19e65e31f3..09c231bbfda 100644 --- a/spec/models/issue_email_participant_spec.rb +++ b/spec/models/issue_email_participant_spec.rb @@ -11,9 +11,14 @@ RSpec.describe IssueEmailParticipant do subject { build(:issue_email_participant) } it { is_expected.to validate_presence_of(:issue) } - it { is_expected.to validate_presence_of(:email) } - it { is_expected.to validate_uniqueness_of(:email).scoped_to([:issue_id]) } + it { is_expected.to validate_uniqueness_of(:email).scoped_to([:issue_id]).ignoring_case_sensitivity } - it_behaves_like 'an object with RFC3696 compliant email-formated attributes', :email + it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :email + + it 'is invalid if the email is nil' do + subject.email = nil + + expect(subject).to be_invalid + end end end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 969d897e551..a3e245f4def 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -1258,4 +1258,23 @@ RSpec.describe Issue do expect { issue.issue_type_supports?(:unkown_feature) }.to raise_error(ArgumentError) end end + + describe '#email_participants_emails' do + let_it_be(:issue) { create(:issue) } + + it 'returns a list of emails' do + participant1 = issue.issue_email_participants.create(email: 'a@gitlab.com') + participant2 = issue.issue_email_participants.create(email: 'b@gitlab.com') + + expect(issue.email_participants_emails).to contain_exactly(participant1.email, participant2.email) + end + end + + describe '#email_participants_downcase' do + it 'returns a list of emails with all uppercase letters replaced with their lowercase counterparts' do + participant = create(:issue_email_participant, email: 'SomEoNe@ExamPLe.com') + + expect(participant.issue.email_participants_emails_downcase).to match([participant.email.downcase]) + end + end end diff --git a/spec/models/iteration_spec.rb b/spec/models/iteration_spec.rb deleted file mode 100644 index e7ec5de0ef1..00000000000 --- a/spec/models/iteration_spec.rb +++ /dev/null @@ -1,335 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Iteration do - let_it_be(:project) { create(:project) } - let_it_be(:group) { create(:group) } - - describe "#iid" do - it "is properly scoped on project and group" do - iteration1 = create(:iteration, :skip_project_validation, project: project) - iteration2 = create(:iteration, :skip_project_validation, project: project) - iteration3 = create(:iteration, group: group) - iteration4 = create(:iteration, group: group) - iteration5 = create(:iteration, :skip_project_validation, project: project) - - want = { - iteration1: 1, - iteration2: 2, - iteration3: 1, - iteration4: 2, - iteration5: 3 - } - got = { - iteration1: iteration1.iid, - iteration2: iteration2.iid, - iteration3: iteration3.iid, - iteration4: iteration4.iid, - iteration5: iteration5.iid - } - expect(got).to eq(want) - end - end - - describe '.filter_by_state' do - let_it_be(:closed_iteration) { create(:iteration, :closed, :skip_future_date_validation, group: group, start_date: 8.days.ago, due_date: 2.days.ago) } - let_it_be(:started_iteration) { create(:iteration, :started, :skip_future_date_validation, group: group, start_date: 1.day.ago, due_date: 6.days.from_now) } - let_it_be(:upcoming_iteration) { create(:iteration, :upcoming, group: group, start_date: 1.week.from_now, due_date: 2.weeks.from_now) } - - shared_examples_for 'filter_by_state' do - it 'filters by the given state' do - expect(described_class.filter_by_state(Iteration.all, state)).to match(expected_iterations) - end - end - - context 'filtering by closed iterations' do - it_behaves_like 'filter_by_state' do - let(:state) { 'closed' } - let(:expected_iterations) { [closed_iteration] } - end - end - - context 'filtering by started iterations' do - it_behaves_like 'filter_by_state' do - let(:state) { 'started' } - let(:expected_iterations) { [started_iteration] } - end - end - - context 'filtering by opened iterations' do - it_behaves_like 'filter_by_state' do - let(:state) { 'opened' } - let(:expected_iterations) { [started_iteration, upcoming_iteration] } - end - end - - context 'filtering by upcoming iterations' do - it_behaves_like 'filter_by_state' do - let(:state) { 'upcoming' } - let(:expected_iterations) { [upcoming_iteration] } - end - end - - context 'filtering by "all"' do - it_behaves_like 'filter_by_state' do - let(:state) { 'all' } - let(:expected_iterations) { [closed_iteration, started_iteration, upcoming_iteration] } - end - end - - context 'filtering by nonexistent filter' do - it 'raises ArgumentError' do - expect { described_class.filter_by_state(Iteration.none, 'unknown') }.to raise_error(ArgumentError, 'Unknown state filter: unknown') - end - end - end - - context 'Validations' do - subject { build(:iteration, group: group, start_date: start_date, due_date: due_date) } - - describe '#not_belonging_to_project' do - subject { build(:iteration, project: project, start_date: Time.current, due_date: 1.day.from_now) } - - it 'is invalid' do - expect(subject).not_to be_valid - expect(subject.errors[:project_id]).to include('is not allowed. We do not currently support project-level iterations') - end - end - - describe '#dates_do_not_overlap' do - let_it_be(:existing_iteration) { create(:iteration, group: group, start_date: 4.days.from_now, due_date: 1.week.from_now) } - - context 'when no Iteration dates overlap' do - let(:start_date) { 2.weeks.from_now } - let(:due_date) { 3.weeks.from_now } - - it { is_expected.to be_valid } - end - - context 'when updated iteration dates overlap with its own dates' do - it 'is valid' do - existing_iteration.start_date = 5.days.from_now - - expect(existing_iteration).to be_valid - end - end - - context 'when dates overlap' do - let(:start_date) { 5.days.from_now } - let(:due_date) { 6.days.from_now } - - shared_examples_for 'overlapping dates' do |skip_constraint_test: false| - context 'when start_date is in range' do - let(:start_date) { 5.days.from_now } - let(:due_date) { 3.weeks.from_now } - - it 'is not valid' do - expect(subject).not_to be_valid - expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations') - end - - unless skip_constraint_test - it 'is not valid even if forced' do - subject.validate # to generate iid/etc - expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/) - end - end - end - - context 'when end_date is in range' do - let(:start_date) { Time.current } - let(:due_date) { 6.days.from_now } - - it 'is not valid' do - expect(subject).not_to be_valid - expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations') - end - - unless skip_constraint_test - it 'is not valid even if forced' do - subject.validate # to generate iid/etc - expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/) - end - end - end - - context 'when both overlap' do - it 'is not valid' do - expect(subject).not_to be_valid - expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations') - end - - unless skip_constraint_test - it 'is not valid even if forced' do - subject.validate # to generate iid/etc - expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/) - end - end - end - end - - context 'group' do - it_behaves_like 'overlapping dates' do - let(:constraint_name) { 'iteration_start_and_due_daterange_group_id_constraint' } - end - - context 'different group' do - let(:group) { create(:group) } - - it { is_expected.to be_valid } - - it 'does not trigger exclusion constraints' do - expect { subject.save! }.not_to raise_exception - end - end - - context 'sub-group' do - let(:subgroup) { create(:group, parent: group) } - - subject { build(:iteration, group: subgroup, start_date: start_date, due_date: due_date) } - - it_behaves_like 'overlapping dates', skip_constraint_test: true - end - end - - context 'project' do - let_it_be(:existing_iteration) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) } - - subject { build(:iteration, :skip_project_validation, project: project, start_date: start_date, due_date: due_date) } - - it_behaves_like 'overlapping dates' do - let(:constraint_name) { 'iteration_start_and_due_daterange_project_id_constraint' } - end - - context 'different project' do - let(:project) { create(:project) } - - it { is_expected.to be_valid } - - it 'does not trigger exclusion constraints' do - expect { subject.save! }.not_to raise_exception - end - end - - context 'in a group' do - let(:group) { create(:group) } - - subject { build(:iteration, group: group, start_date: start_date, due_date: due_date) } - - it { is_expected.to be_valid } - - it 'does not trigger exclusion constraints' do - expect { subject.save! }.not_to raise_exception - end - end - end - - context 'project in a group' do - let_it_be(:project) { create(:project, group: create(:group)) } - let_it_be(:existing_iteration) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) } - - subject { build(:iteration, :skip_project_validation, project: project, start_date: start_date, due_date: due_date) } - - it_behaves_like 'overlapping dates' do - let(:constraint_name) { 'iteration_start_and_due_daterange_project_id_constraint' } - end - end - end - end - - describe '#future_date' do - context 'when dates are in the future' do - let(:start_date) { Time.current } - let(:due_date) { 1.week.from_now } - - it { is_expected.to be_valid } - end - - context 'when start_date is in the past' do - let(:start_date) { 1.week.ago } - let(:due_date) { 1.week.from_now } - - it 'is not valid' do - expect(subject).not_to be_valid - expect(subject.errors[:start_date]).to include('cannot be in the past') - end - end - - context 'when due_date is in the past' do - let(:start_date) { Time.current } - let(:due_date) { 1.week.ago } - - it 'is not valid' do - expect(subject).not_to be_valid - expect(subject.errors[:due_date]).to include('cannot be in the past') - end - end - - context 'when start_date is over 500 years in the future' do - let(:start_date) { 501.years.from_now } - let(:due_date) { Time.current } - - it 'is not valid' do - expect(subject).not_to be_valid - expect(subject.errors[:start_date]).to include('cannot be more than 500 years in the future') - end - end - - context 'when due_date is over 500 years in the future' do - let(:start_date) { Time.current } - let(:due_date) { 501.years.from_now } - - it 'is not valid' do - expect(subject).not_to be_valid - expect(subject.errors[:due_date]).to include('cannot be more than 500 years in the future') - end - end - end - end - - context 'time scopes' do - let_it_be(:project) { create(:project, :empty_repo) } - let_it_be(:iteration_1) { create(:iteration, :skip_future_date_validation, :skip_project_validation, project: project, start_date: 3.days.ago, due_date: 1.day.from_now) } - let_it_be(:iteration_2) { create(:iteration, :skip_future_date_validation, :skip_project_validation, project: project, start_date: 10.days.ago, due_date: 4.days.ago) } - let_it_be(:iteration_3) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) } - - describe 'start_date_passed' do - it 'returns iterations where start_date is in the past but due_date is in the future' do - expect(described_class.start_date_passed).to contain_exactly(iteration_1) - end - end - - describe 'due_date_passed' do - it 'returns iterations where due date is in the past' do - expect(described_class.due_date_passed).to contain_exactly(iteration_2) - end - end - end - - describe '.within_timeframe' do - let_it_be(:now) { Time.current } - let_it_be(:project) { create(:project, :empty_repo) } - let_it_be(:iteration_1) { create(:iteration, :skip_project_validation, project: project, start_date: now, due_date: 1.day.from_now) } - let_it_be(:iteration_2) { create(:iteration, :skip_project_validation, project: project, start_date: 2.days.from_now, due_date: 3.days.from_now) } - let_it_be(:iteration_3) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) } - - it 'returns iterations with start_date and/or end_date between timeframe' do - iterations = described_class.within_timeframe(2.days.from_now, 3.days.from_now) - - expect(iterations).to match_array([iteration_2]) - end - - it 'returns iterations which starts before the timeframe' do - iterations = described_class.within_timeframe(1.day.from_now, 3.days.from_now) - - expect(iterations).to match_array([iteration_1, iteration_2]) - end - - it 'returns iterations which ends after the timeframe' do - iterations = described_class.within_timeframe(3.days.from_now, 5.days.from_now) - - expect(iterations).to match_array([iteration_2, iteration_3]) - end - end -end diff --git a/spec/models/list_spec.rb b/spec/models/list_spec.rb index f0b1bc33e84..ad07ee1115b 100644 --- a/spec/models/list_spec.rb +++ b/spec/models/list_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe List do it_behaves_like 'having unique enum values' it_behaves_like 'boards listable model', :list + it_behaves_like 'list_preferences_for user', :list, :list_id describe 'relationships' do it { is_expected.to belong_to(:board) } @@ -17,70 +18,16 @@ RSpec.describe List do it { is_expected.to validate_presence_of(:list_type) } end - describe '#update_preferences_for' do - let(:user) { create(:user) } - let(:list) { create(:list) } + describe '.without_types' do + it 'exclude lists of given types' do + board = create(:list, list_type: :label).board + # closed list is created by default + backlog_list = create(:list, list_type: :backlog, board: board) - context 'when user is present' do - context 'when there are no preferences for user' do - it 'creates new user preferences' do - expect { list.update_preferences_for(user, collapsed: true) }.to change { ListUserPreference.count }.by(1) - expect(list.preferences_for(user).collapsed).to eq(true) - end - end + exclude_type = [described_class.list_types[:label], described_class.list_types[:closed]] - context 'when there are preferences for user' do - it 'updates user preferences' do - list.update_preferences_for(user, collapsed: false) - - expect { list.update_preferences_for(user, collapsed: true) }.not_to change { ListUserPreference.count } - expect(list.preferences_for(user).collapsed).to eq(true) - end - end - - context 'when user is nil' do - it 'does not create user preferences' do - expect { list.update_preferences_for(nil, collapsed: true) }.not_to change { ListUserPreference.count } - end - end - end - end - - describe '#preferences_for' do - let(:user) { create(:user) } - let(:list) { create(:list) } - - context 'when user is nil' do - it 'returns not persisted preferences' do - preferences = list.preferences_for(nil) - - expect(preferences.persisted?).to eq(false) - expect(preferences.list_id).to eq(list.id) - expect(preferences.user_id).to be_nil - end - end - - context 'when a user preference already exists' do - before do - list.update_preferences_for(user, collapsed: true) - end - - it 'loads preference for user' do - preferences = list.preferences_for(user) - - expect(preferences).to be_persisted - expect(preferences.collapsed).to eq(true) - end - end - - context 'when preferences for user does not exist' do - it 'returns not persisted preferences' do - preferences = list.preferences_for(user) - - expect(preferences.persisted?).to eq(false) - expect(preferences.user_id).to eq(user.id) - expect(preferences.list_id).to eq(list.id) - end + lists = described_class.without_types(exclude_type) + expect(lists.where(board: board)).to match_array([backlog_list]) end end end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index b60af7abade..c41f466456f 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -24,7 +24,7 @@ RSpec.describe Member do it { is_expected.to allow_value(nil).for(:expires_at) } end - it_behaves_like 'an object with email-formated attributes', :invite_email do + it_behaves_like 'an object with email-formatted attributes', :invite_email do subject { build(:project_member) } end @@ -130,14 +130,18 @@ RSpec.describe Member do @maintainer_user = create(:user).tap { |u| project.add_maintainer(u) } @maintainer = project.members.find_by(user_id: @maintainer_user.id) - @blocked_user = create(:user).tap do |u| + @blocked_maintainer_user = create(:user).tap do |u| project.add_maintainer(u) + + u.block! + end + @blocked_developer_user = create(:user).tap do |u| project.add_developer(u) u.block! end - @blocked_maintainer = project.members.find_by(user_id: @blocked_user.id, access_level: Gitlab::Access::MAINTAINER) - @blocked_developer = project.members.find_by(user_id: @blocked_user.id, access_level: Gitlab::Access::DEVELOPER) + @blocked_maintainer = project.members.find_by(user_id: @blocked_maintainer_user.id, access_level: Gitlab::Access::MAINTAINER) + @blocked_developer = project.members.find_by(user_id: @blocked_developer_user.id, access_level: Gitlab::Access::DEVELOPER) @invited_member = create(:project_member, :developer, project: project, @@ -161,7 +165,7 @@ RSpec.describe Member do describe '.access_for_user_ids' do it 'returns the right access levels' do - users = [@owner_user.id, @maintainer_user.id, @blocked_user.id] + users = [@owner_user.id, @maintainer_user.id, @blocked_maintainer_user.id] expected = { @owner_user.id => Gitlab::Access::OWNER, @maintainer_user.id => Gitlab::Access::MAINTAINER @@ -382,6 +386,20 @@ RSpec.describe Member do it { is_expected.not_to include @member_with_minimal_access } end + describe '.blocked' do + subject { described_class.blocked.to_a } + + it { is_expected.not_to include @owner } + it { is_expected.not_to include @maintainer } + it { is_expected.not_to include @invited_member } + it { is_expected.not_to include @accepted_invite_member } + it { is_expected.not_to include @requested_member } + it { is_expected.not_to include @accepted_request_member } + it { is_expected.to include @blocked_maintainer } + it { is_expected.to include @blocked_developer } + it { is_expected.not_to include @member_with_minimal_access } + end + describe '.active_without_invites_and_requests' do subject { described_class.active_without_invites_and_requests.to_a } @@ -425,12 +443,10 @@ RSpec.describe Member do end context 'when admin mode is disabled' do - # Skipped because `Group#max_member_access_for_user` needs to be migrated to use admin mode - # https://gitlab.com/gitlab-org/gitlab/-/issues/207950 - xit 'rejects setting members.created_by to the given admin current_user' do + it 'rejects setting members.created_by to the given admin current_user' do member = described_class.add_user(source, user, :maintainer, current_user: admin) - expect(member.created_by).not_to be_persisted + expect(member.created_by).to be_nil end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index ebe2cd2ac03..8c7289adbcc 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -9,8 +9,8 @@ RSpec.describe MergeRequest, factory_default: :keep do using RSpec::Parameterized::TableSyntax - let_it_be(:namespace) { create_default(:namespace) } - let_it_be(:project, refind: true) { create_default(:project, :repository) } + let_it_be(:namespace) { create_default(:namespace).freeze } + let_it_be(:project, refind: true) { create_default(:project, :repository).freeze } subject { create(:merge_request) } @@ -1366,6 +1366,10 @@ RSpec.describe MergeRequest, factory_default: :keep do it "doesn't detect WIP by default" do expect(subject.work_in_progress?).to eq false end + + it "is aliased to #draft?" do + expect(subject.method(:work_in_progress?)).to eq(subject.method(:draft?)) + end end describe "#wipless_title" do @@ -2895,6 +2899,14 @@ RSpec.describe MergeRequest, factory_default: :keep do expect(subject.mergeable?).to be_truthy end + it 'return true if #mergeable_state? is true and the MR #can_be_merged? is false' do + allow(subject).to receive(:mergeable_state?) { true } + expect(subject).to receive(:check_mergeability) + expect(subject).to receive(:can_be_merged?) { false } + + expect(subject.mergeable?).to be_falsey + end + context 'with skip_ci_check option' do before do allow(subject).to receive_messages(check_mergeability: nil, @@ -3072,6 +3084,7 @@ RSpec.describe MergeRequest, factory_default: :keep do where(:status, :public_status) do 'cannot_be_merged_rechecking' | 'checking' + 'preparing' | 'checking' 'checking' | 'checking' 'cannot_be_merged' | 'cannot_be_merged' end @@ -3082,32 +3095,83 @@ RSpec.describe MergeRequest, factory_default: :keep do end describe "#head_pipeline_active? " do - it do - is_expected - .to delegate_method(:active?) - .to(:head_pipeline) - .with_prefix - .with_arguments(allow_nil: true) + context 'when project lacks a head_pipeline relation' do + before do + subject.head_pipeline = nil + end + + it 'returns false' do + expect(subject.head_pipeline_active?).to be false + end + end + + context 'when project has a head_pipeline relation' do + let(:pipeline) { create(:ci_empty_pipeline) } + + before do + allow(subject).to receive(:head_pipeline) { pipeline } + end + + it 'accesses the value from the head_pipeline' do + expect(subject.head_pipeline) + .to receive(:active?) + + subject.head_pipeline_active? + end end end describe "#actual_head_pipeline_success? " do - it do - is_expected - .to delegate_method(:success?) - .to(:actual_head_pipeline) - .with_prefix - .with_arguments(allow_nil: true) + context 'when project lacks an actual_head_pipeline relation' do + before do + allow(subject).to receive(:actual_head_pipeline) { nil } + end + + it 'returns false' do + expect(subject.actual_head_pipeline_success?).to be false + end + end + + context 'when project has a actual_head_pipeline relation' do + let(:pipeline) { create(:ci_empty_pipeline) } + + before do + allow(subject).to receive(:actual_head_pipeline) { pipeline } + end + + it 'accesses the value from the actual_head_pipeline' do + expect(subject.actual_head_pipeline) + .to receive(:success?) + + subject.actual_head_pipeline_success? + end end end describe "#actual_head_pipeline_active? " do - it do - is_expected - .to delegate_method(:active?) - .to(:actual_head_pipeline) - .with_prefix - .with_arguments(allow_nil: true) + context 'when project lacks an actual_head_pipeline relation' do + before do + allow(subject).to receive(:actual_head_pipeline) { nil } + end + + it 'returns false' do + expect(subject.actual_head_pipeline_active?).to be false + end + end + + context 'when project has a actual_head_pipeline relation' do + let(:pipeline) { create(:ci_empty_pipeline) } + + before do + allow(subject).to receive(:actual_head_pipeline) { pipeline } + end + + it 'accesses the value from the actual_head_pipeline' do + expect(subject.actual_head_pipeline) + .to receive(:active?) + + subject.actual_head_pipeline_active? + end end end @@ -3784,6 +3848,87 @@ RSpec.describe MergeRequest, factory_default: :keep do end end + describe '#use_merge_base_pipeline_for_comparison?' do + let(:project) { create(:project, :public, :repository) } + let(:merge_request) { create(:merge_request, :with_codequality_reports, source_project: project) } + + subject { merge_request.use_merge_base_pipeline_for_comparison?(service_class) } + + context 'when service class is Ci::CompareCodequalityReportsService' do + let(:service_class) { 'Ci::CompareCodequalityReportsService' } + + context 'when feature flag is enabled' do + it { is_expected.to be_truthy } + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(codequality_backend_comparison: false) + end + + it { is_expected.to be_falsey } + end + end + + context 'when service class is different' do + let(:service_class) { 'Ci::GenerateCoverageReportsService' } + + it { is_expected.to be_falsey } + end + end + + describe '#comparison_base_pipeline' do + subject(:pipeline) { merge_request.comparison_base_pipeline(service_class) } + + let(:project) { create(:project, :public, :repository) } + let(:merge_request) { create(:merge_request, :with_codequality_reports, source_project: project) } + let!(:base_pipeline) do + create(:ci_pipeline, + :with_test_reports, + project: project, + ref: merge_request.target_branch, + sha: merge_request.diff_base_sha + ) + end + + context 'when service class is Ci::CompareCodequalityReportsService' do + let(:service_class) { 'Ci::CompareCodequalityReportsService' } + + context 'when merge request has a merge request pipeline' do + let(:merge_request) do + create(:merge_request, :with_merge_request_pipeline) + end + + let(:merge_base_pipeline) do + create(:ci_pipeline, ref: merge_request.target_branch, sha: merge_request.target_branch_sha) + end + + before do + merge_base_pipeline + merge_request.update_head_pipeline + end + + it 'returns the merge_base_pipeline' do + expect(pipeline).to eq(merge_base_pipeline) + end + end + + context 'when merge does not have a merge request pipeline' do + it 'returns the base_pipeline' do + expect(pipeline).to eq(base_pipeline) + end + end + end + + context 'when service_class is different' do + let(:service_class) { 'Ci::GenerateCoverageReportsService' } + + it 'returns the base_pipeline' do + expect(pipeline).to eq(base_pipeline) + end + end + end + describe '#base_pipeline' do let(:pipeline_arguments) do { @@ -3963,6 +4108,65 @@ RSpec.describe MergeRequest, factory_default: :keep do end end + describe '#mark_as_unchecked' do + subject { create(:merge_request, source_project: project, merge_status: merge_status) } + + shared_examples 'for an invalid state transition' do + it 'is not a valid state transition' do + expect { subject.mark_as_unchecked! }.to raise_error(StateMachines::InvalidTransition) + end + end + + shared_examples 'for an valid state transition' do + it 'is a valid state transition' do + expect { subject.mark_as_unchecked! } + .to change { subject.merge_status } + .from(merge_status.to_s) + .to(expected_merge_status) + end + end + + context 'when the status is unchecked' do + let(:merge_status) { :unchecked } + + include_examples 'for an invalid state transition' + end + + context 'when the status is checking' do + let(:merge_status) { :checking } + let(:expected_merge_status) { 'unchecked' } + + include_examples 'for an valid state transition' + end + + context 'when the status is can_be_merged' do + let(:merge_status) { :can_be_merged } + let(:expected_merge_status) { 'unchecked' } + + include_examples 'for an valid state transition' + end + + context 'when the status is cannot_be_merged_recheck' do + let(:merge_status) { :cannot_be_merged_recheck } + + include_examples 'for an invalid state transition' + end + + context 'when the status is cannot_be_merged' do + let(:merge_status) { :cannot_be_merged } + let(:expected_merge_status) { 'cannot_be_merged_recheck' } + + include_examples 'for an valid state transition' + end + + context 'when the status is cannot_be_merged' do + let(:merge_status) { :cannot_be_merged } + let(:expected_merge_status) { 'cannot_be_merged_recheck' } + + include_examples 'for an valid state transition' + end + end + describe 'transition to cannot_be_merged' do let(:notification_service) { double(:notification_service) } let(:todo_service) { double(:todo_service) } @@ -4661,4 +4865,33 @@ RSpec.describe MergeRequest, factory_default: :keep do end end end + + describe '#includes_ci_config?' do + let(:merge_request) { build(:merge_request) } + let(:project) { merge_request.project } + + subject(:result) { merge_request.includes_ci_config? } + + before do + allow(merge_request).to receive(:diff_stats).and_return(diff_stats) + end + + context 'when diff_stats is nil' do + let(:diff_stats) {} + + it { is_expected.to eq(false) } + end + + context 'when diff_stats does not include the ci config path of the project' do + let(:diff_stats) { [double(path: 'abc.txt')] } + + it { is_expected.to eq(false) } + end + + context 'when diff_stats includes the ci config path of the project' do + let(:diff_stats) { [double(path: '.gitlab-ci.yml')] } + + it { is_expected.to eq(true) } + end + end end diff --git a/spec/models/namespace/traversal_hierarchy_spec.rb b/spec/models/namespace/traversal_hierarchy_spec.rb index 71b0e974106..83e6d704640 100644 --- a/spec/models/namespace/traversal_hierarchy_spec.rb +++ b/spec/models/namespace/traversal_hierarchy_spec.rb @@ -3,41 +3,41 @@ require 'spec_helper' RSpec.describe Namespace::TraversalHierarchy, type: :model do - let_it_be(:root, reload: true) { create(:namespace, :with_hierarchy) } + let_it_be(:root, reload: true) { create(:group, :with_hierarchy) } describe '.for_namespace' do - let(:hierarchy) { described_class.for_namespace(namespace) } + let(:hierarchy) { described_class.for_namespace(group) } context 'with root group' do - let(:namespace) { root } + let(:group) { root } it { expect(hierarchy.root).to eq root } end context 'with child group' do - let(:namespace) { root.children.first.children.first } + let(:group) { root.children.first.children.first } it { expect(hierarchy.root).to eq root } end context 'with group outside of hierarchy' do - let(:namespace) { create(:namespace) } + let(:group) { create(:namespace) } it { expect(hierarchy.root).not_to eq root } end end describe '.new' do - let(:hierarchy) { described_class.new(namespace) } + let(:hierarchy) { described_class.new(group) } context 'with root group' do - let(:namespace) { root } + let(:group) { root } it { expect(hierarchy.root).to eq root } end context 'with child group' do - let(:namespace) { root.children.first } + let(:group) { root.children.first } it { expect { hierarchy }.to raise_error(StandardError, 'Must specify a root node') } end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 647e279bf83..65d787d334b 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Namespace do include ProjectForksHelper include GitHelpers - let!(:namespace) { create(:namespace) } + let!(:namespace) { create(:namespace, :with_namespace_settings) } let(:gitlab_shell) { Gitlab::Shell.new } let(:repository_storage) { 'default' } @@ -32,14 +32,68 @@ RSpec.describe Namespace do it { is_expected.to validate_presence_of(:owner) } it { is_expected.to validate_numericality_of(:max_artifacts_size).only_integer.is_greater_than(0) } + context 'validating the parent of a namespace' do + context 'when the namespace has no parent' do + it 'allows a namespace to have no parent associated with it' do + namespace = build(:namespace) + + expect(namespace).to be_valid + end + end + + context 'when the namespace has a parent' do + it 'does not allow a namespace to have a group as its parent' do + namespace = build(:namespace, parent: build(:group)) + + expect(namespace).not_to be_valid + expect(namespace.errors[:parent_id].first).to eq('a user namespace cannot have a parent') + end + + it 'does not allow a namespace to have another namespace as its parent' do + namespace = build(:namespace, parent: build(:namespace)) + + expect(namespace).not_to be_valid + expect(namespace.errors[:parent_id].first).to eq('a user namespace cannot have a parent') + end + end + + context 'when the feature flag `validate_namespace_parent_type` is disabled' do + before do + stub_feature_flags(validate_namespace_parent_type: false) + end + + context 'when the namespace has no parent' do + it 'allows a namespace to have no parent associated with it' do + namespace = build(:namespace) + + expect(namespace).to be_valid + end + end + + context 'when the namespace has a parent' do + it 'allows a namespace to have a group as its parent' do + namespace = build(:namespace, parent: build(:group)) + + expect(namespace).to be_valid + end + + it 'allows a namespace to have another namespace as its parent' do + namespace = build(:namespace, parent: build(:namespace)) + + expect(namespace).to be_valid + end + end + end + end + it 'does not allow too deep nesting' do ancestors = (1..21).to_a - nested = build(:namespace, parent: namespace) + group = build(:group) - allow(nested).to receive(:ancestors).and_return(ancestors) + allow(group).to receive(:ancestors).and_return(ancestors) - expect(nested).not_to be_valid - expect(nested.errors[:parent_id].first).to eq('has too deep level of nesting') + expect(group).not_to be_valid + expect(group.errors[:parent_id].first).to eq('has too deep level of nesting') end describe 'reserved path validation' do @@ -116,6 +170,28 @@ RSpec.describe Namespace do it { is_expected.to include_module(Namespaces::Traversal::Recursive) } end + describe 'callbacks' do + describe 'before_save :ensure_delayed_project_removal_assigned_to_namespace_settings' do + it 'sets the matching value in namespace_settings' do + expect { namespace.update!(delayed_project_removal: true) }.to change { + namespace.namespace_settings.delayed_project_removal + }.from(false).to(true) + end + + context 'when the feature flag is disabled' do + before do + stub_feature_flags(migrate_delayed_project_removal: false) + end + + it 'does not set the matching value in namespace_settings' do + expect { namespace.update!(delayed_project_removal: true) }.not_to change { + namespace.namespace_settings.delayed_project_removal + } + end + end + end + end + describe '#visibility_level_field' do it { expect(namespace.visibility_level_field).to eq(:visibility_level) } end @@ -150,45 +226,45 @@ RSpec.describe Namespace do end describe '.search' do - let_it_be(:first_namespace) { build(:namespace, name: 'my first namespace', path: 'old-path').tap(&:save!) } - let_it_be(:parent_namespace) { build(:namespace, name: 'my parent namespace', path: 'parent-path').tap(&:save!) } - let_it_be(:second_namespace) { build(:namespace, name: 'my second namespace', path: 'new-path', parent: parent_namespace).tap(&:save!) } - let_it_be(:project_with_same_path) { create(:project, id: second_namespace.id, path: first_namespace.path) } + let_it_be(:first_group) { build(:group, name: 'my first namespace', path: 'old-path').tap(&:save!) } + let_it_be(:parent_group) { build(:group, name: 'my parent namespace', path: 'parent-path').tap(&:save!) } + let_it_be(:second_group) { build(:group, name: 'my second namespace', path: 'new-path', parent: parent_group).tap(&:save!) } + let_it_be(:project_with_same_path) { create(:project, id: second_group.id, path: first_group.path) } it 'returns namespaces with a matching name' do - expect(described_class.search('my first namespace')).to eq([first_namespace]) + expect(described_class.search('my first namespace')).to eq([first_group]) end it 'returns namespaces with a partially matching name' do - expect(described_class.search('first')).to eq([first_namespace]) + expect(described_class.search('first')).to eq([first_group]) end it 'returns namespaces with a matching name regardless of the casing' do - expect(described_class.search('MY FIRST NAMESPACE')).to eq([first_namespace]) + expect(described_class.search('MY FIRST NAMESPACE')).to eq([first_group]) end it 'returns namespaces with a matching path' do - expect(described_class.search('old-path')).to eq([first_namespace]) + expect(described_class.search('old-path')).to eq([first_group]) end it 'returns namespaces with a partially matching path' do - expect(described_class.search('old')).to eq([first_namespace]) + expect(described_class.search('old')).to eq([first_group]) end it 'returns namespaces with a matching path regardless of the casing' do - expect(described_class.search('OLD-PATH')).to eq([first_namespace]) + expect(described_class.search('OLD-PATH')).to eq([first_group]) end it 'returns namespaces with a matching route path' do - expect(described_class.search('parent-path/new-path', include_parents: true)).to eq([second_namespace]) + expect(described_class.search('parent-path/new-path', include_parents: true)).to eq([second_group]) end it 'returns namespaces with a partially matching route path' do - expect(described_class.search('parent-path/new', include_parents: true)).to eq([second_namespace]) + expect(described_class.search('parent-path/new', include_parents: true)).to eq([second_group]) end it 'returns namespaces with a matching route path regardless of the casing' do - expect(described_class.search('PARENT-PATH/NEW-PATH', include_parents: true)).to eq([second_namespace]) + expect(described_class.search('PARENT-PATH/NEW-PATH', include_parents: true)).to eq([second_group]) end end @@ -285,6 +361,18 @@ RSpec.describe Namespace do end end + describe '.top_most' do + let_it_be(:namespace) { create(:namespace) } + let_it_be(:group) { create(:group) } + let_it_be(:subgroup) { create(:group, parent: group) } + + subject { described_class.top_most.ids } + + it 'only contains root namespaces' do + is_expected.to contain_exactly(group.id, namespace.id) + end + end + describe '#ancestors_upto' do let(:parent) { create(:group) } let(:child) { create(:group, parent: parent) } @@ -800,7 +888,7 @@ RSpec.describe Namespace do end describe '#all_projects' do - shared_examples 'all projects for a group' do + context 'when namespace is a group' do let(:namespace) { create(:group) } let(:child) { create(:group, parent: namespace) } let!(:project1) { create(:project_empty_repo, namespace: namespace) } @@ -808,49 +896,39 @@ RSpec.describe Namespace do it { expect(namespace.all_projects.to_a).to match_array([project2, project1]) } it { expect(child.all_projects.to_a).to match_array([project2]) } - end - - shared_examples 'all projects for personal namespace' do - let_it_be(:user) { create(:user) } - let_it_be(:user_namespace) { create(:namespace, owner: user) } - let_it_be(:project) { create(:project, namespace: user_namespace) } - it { expect(user_namespace.all_projects.to_a).to match_array([project]) } - end - - context 'with recursive approach' do - context 'when namespace is a group' do - include_examples 'all projects for a group' + context 'when recursive_namespace_lookup_as_inner_join feature flag is on' do + before do + stub_feature_flags(recursive_namespace_lookup_as_inner_join: true) + end it 'queries for the namespace and its descendants' do - expect(Project).to receive(:where).with(namespace: [namespace, child]) - - namespace.all_projects + expect(namespace.all_projects).to match_array([project1, project2]) end end - context 'when namespace is a user namespace' do - include_examples 'all projects for personal namespace' - - it 'only queries for the namespace itself' do - expect(Project).to receive(:where).with(namespace: user_namespace) + context 'when recursive_namespace_lookup_as_inner_join feature flag is off' do + before do + stub_feature_flags(recursive_namespace_lookup_as_inner_join: false) + end - user_namespace.all_projects + it 'queries for the namespace and its descendants' do + expect(namespace.all_projects).to match_array([project1, project2]) end end end - context 'with route path wildcard approach' do - before do - stub_feature_flags(recursive_approach_for_all_projects: false) - end + context 'when namespace is a user namespace' do + let_it_be(:user) { create(:user) } + let_it_be(:user_namespace) { create(:namespace, owner: user) } + let_it_be(:project) { create(:project, namespace: user_namespace) } - context 'when namespace is a group' do - include_examples 'all projects for a group' - end + it { expect(user_namespace.all_projects.to_a).to match_array([project]) } + + it 'only queries for the namespace itself' do + expect(Project).to receive(:where).with(namespace: user_namespace) - context 'when namespace is a user namespace' do - include_examples 'all projects for personal namespace' + user_namespace.all_projects end end end @@ -1250,14 +1328,14 @@ RSpec.describe Namespace do using RSpec::Parameterized::TableSyntax shared_examples_for 'fetching closest setting' do - let!(:root_namespace) { create(:namespace) } - let!(:namespace) { create(:namespace, parent: root_namespace) } + let!(:parent) { create(:group) } + let!(:group) { create(:group, parent: parent) } - let(:setting) { namespace.closest_setting(setting_name) } + let(:setting) { group.closest_setting(setting_name) } before do - root_namespace.update_attribute(setting_name, root_setting) - namespace.update_attribute(setting_name, child_setting) + parent.update_attribute(setting_name, root_setting) + group.update_attribute(setting_name, child_setting) end it 'returns closest non-nil value' do @@ -1348,30 +1426,30 @@ RSpec.describe Namespace do context 'with a parent' do context 'when parent has shared runners disabled' do - let(:parent) { create(:namespace, :shared_runners_disabled) } - let(:sub_namespace) { build(:namespace, shared_runners_enabled: true, parent_id: parent.id) } + let(:parent) { create(:group, :shared_runners_disabled) } + let(:group) { build(:group, shared_runners_enabled: true, parent_id: parent.id) } it 'is invalid' do - expect(sub_namespace).to be_invalid - expect(sub_namespace.errors[:shared_runners_enabled]).to include('cannot be enabled because parent group has shared Runners disabled') + expect(group).to be_invalid + expect(group.errors[:shared_runners_enabled]).to include('cannot be enabled because parent group has shared Runners disabled') end end context 'when parent has shared runners disabled but allows override' do - let(:parent) { create(:namespace, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners) } - let(:sub_namespace) { build(:namespace, shared_runners_enabled: true, parent_id: parent.id) } + let(:parent) { create(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners) } + let(:group) { build(:group, shared_runners_enabled: true, parent_id: parent.id) } it 'is valid' do - expect(sub_namespace).to be_valid + expect(group).to be_valid end end context 'when parent has shared runners enabled' do - let(:parent) { create(:namespace, shared_runners_enabled: true) } - let(:sub_namespace) { build(:namespace, shared_runners_enabled: true, parent_id: parent.id) } + let(:parent) { create(:group, shared_runners_enabled: true) } + let(:group) { build(:group, shared_runners_enabled: true, parent_id: parent.id) } it 'is valid' do - expect(sub_namespace).to be_valid + expect(group).to be_valid end end end @@ -1401,30 +1479,30 @@ RSpec.describe Namespace do context 'with a parent' do context 'when parent does not allow shared runners' do - let(:parent) { create(:namespace, :shared_runners_disabled) } - let(:sub_namespace) { build(:namespace, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent_id: parent.id) } + let(:parent) { create(:group, :shared_runners_disabled) } + let(:group) { build(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent_id: parent.id) } it 'is invalid' do - expect(sub_namespace).to be_invalid - expect(sub_namespace.errors[:allow_descendants_override_disabled_shared_runners]).to include('cannot be enabled because parent group does not allow it') + expect(group).to be_invalid + expect(group.errors[:allow_descendants_override_disabled_shared_runners]).to include('cannot be enabled because parent group does not allow it') end end context 'when parent allows shared runners and setting to true' do - let(:parent) { create(:namespace, shared_runners_enabled: true) } - let(:sub_namespace) { build(:namespace, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent_id: parent.id) } + let(:parent) { create(:group, shared_runners_enabled: true) } + let(:group) { build(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent_id: parent.id) } it 'is valid' do - expect(sub_namespace).to be_valid + expect(group).to be_valid end end context 'when parent allows shared runners and setting to false' do - let(:parent) { create(:namespace, shared_runners_enabled: true) } - let(:sub_namespace) { build(:namespace, :shared_runners_disabled, allow_descendants_override_disabled_shared_runners: false, parent_id: parent.id) } + let(:parent) { create(:group, shared_runners_enabled: true) } + let(:group) { build(:group, :shared_runners_disabled, allow_descendants_override_disabled_shared_runners: false, parent_id: parent.id) } it 'is valid' do - expect(sub_namespace).to be_valid + expect(group).to be_valid end end end @@ -1449,4 +1527,24 @@ RSpec.describe Namespace do end end end + + describe '#recent?' do + subject { namespace.recent? } + + context 'when created more than 90 days ago' do + before do + namespace.update_attribute(:created_at, 91.days.ago) + end + + it { is_expected.to be(false) } + end + + context 'when created less than 90 days ago' do + before do + namespace.update_attribute(:created_at, 89.days.ago) + end + + it { is_expected.to be(true) } + end + end end diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 364b80e8601..590acfc0ac1 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -336,6 +336,25 @@ RSpec.describe Note do end end + describe "last_edited_at" do + let(:timestamp) { Time.current } + let(:note) { build(:note, last_edited_at: nil, created_at: timestamp, updated_at: timestamp + 5.hours) } + + context "with last_edited_at" do + it "returns last_edited_at" do + note.last_edited_at = timestamp + + expect(note.last_edited_at).to eq(timestamp) + end + end + + context "without last_edited_at" do + it "returns updated_at" do + expect(note.last_edited_at).to eq(timestamp + 5.hours) + end + end + end + describe "edited?" do let(:note) { build(:note, updated_by_id: nil, created_at: Time.current, updated_at: Time.current + 5.hours) } diff --git a/spec/models/notification_recipient_spec.rb b/spec/models/notification_recipient_spec.rb index 8429f577dc6..4debda0621c 100644 --- a/spec/models/notification_recipient_spec.rb +++ b/spec/models/notification_recipient_spec.rb @@ -337,6 +337,39 @@ RSpec.describe NotificationRecipient do expect(recipient.suitable_notification_level?).to eq true end end + + context 'with merge_when_pipeline_succeeds' do + let(:notification_setting) { user.notification_settings_for(project) } + let(:recipient) do + described_class.new( + user, + :watch, + custom_action: :merge_when_pipeline_succeeds, + target: target, + project: project + ) + end + + context 'custom event enabled' do + before do + notification_setting.update!(merge_when_pipeline_succeeds: true) + end + + it 'returns true' do + expect(recipient.suitable_notification_level?).to eq true + end + end + + context 'custom event disabled' do + before do + notification_setting.update!(merge_when_pipeline_succeeds: false) + end + + it 'returns false' do + expect(recipient.suitable_notification_level?).to eq false + end + end + end end end diff --git a/spec/models/notification_setting_spec.rb b/spec/models/notification_setting_spec.rb index bc50e2af373..4ef5ab7af48 100644 --- a/spec/models/notification_setting_spec.rb +++ b/spec/models/notification_setting_spec.rb @@ -180,7 +180,8 @@ RSpec.describe NotificationSetting do :failed_pipeline, :success_pipeline, :fixed_pipeline, - :moved_project + :moved_project, + :merge_when_pipeline_succeeds ) end diff --git a/spec/models/onboarding_progress_spec.rb b/spec/models/onboarding_progress_spec.rb index 0aa19345a25..779312c9fa0 100644 --- a/spec/models/onboarding_progress_spec.rb +++ b/spec/models/onboarding_progress_spec.rb @@ -106,7 +106,7 @@ RSpec.describe OnboardingProgress do end context 'when not given a root namespace' do - let(:namespace) { create(:namespace, parent: build(:namespace)) } + let(:namespace) { create(:group, parent: build(:group)) } it 'does not add a record for the namespace' do expect { onboard }.not_to change(described_class, :count).from(0) @@ -182,6 +182,30 @@ RSpec.describe OnboardingProgress do end end + describe '.not_completed?' do + subject { described_class.not_completed?(namespace.id, action) } + + context 'when the namespace has not yet been onboarded' do + it { is_expected.to be(false) } + end + + context 'when the namespace has been onboarded but not registered the action yet' do + before do + described_class.onboard(namespace) + end + + it { is_expected.to be(true) } + + context 'when the action has been registered' do + before do + described_class.register(namespace, action) + end + + it { is_expected.to be(false) } + end + end + end + describe '.column_name' do subject { described_class.column_name(action) } diff --git a/spec/models/packages/maven/metadatum_spec.rb b/spec/models/packages/maven/metadatum_spec.rb index 16f6929d710..94a0e558985 100644 --- a/spec/models/packages/maven/metadatum_spec.rb +++ b/spec/models/packages/maven/metadatum_spec.rb @@ -36,5 +36,38 @@ RSpec.describe Packages::Maven::Metadatum, type: :model do expect(maven_metadatum.errors.to_a).to include('Package type must be Maven') end end + + context 'with a package' do + let_it_be(:package) { create(:package) } + + describe '.for_package_ids' do + let_it_be(:metadata) { create_list(:maven_metadatum, 3, package: package) } + + subject { Packages::Maven::Metadatum.for_package_ids(package.id) } + + it { is_expected.to match_array(metadata) } + end + + describe '.order_created' do + let_it_be(:metadatum1) { create(:maven_metadatum, package: package) } + let_it_be(:metadatum2) { create(:maven_metadatum, package: package) } + let_it_be(:metadatum3) { create(:maven_metadatum, package: package) } + let_it_be(:metadatum4) { create(:maven_metadatum, package: package) } + + subject { Packages::Maven::Metadatum.for_package_ids(package.id).order_created } + + it { is_expected.to eq([metadatum1, metadatum2, metadatum3, metadatum4]) } + end + + describe '.pluck_app_name' do + let_it_be(:metadatum1) { create(:maven_metadatum, package: package, app_name: 'one') } + let_it_be(:metadatum2) { create(:maven_metadatum, package: package, app_name: 'two') } + let_it_be(:metadatum3) { create(:maven_metadatum, package: package, app_name: 'three') } + + subject { Packages::Maven::Metadatum.for_package_ids(package.id).pluck_app_name } + + it { is_expected.to match_array([metadatum1, metadatum2, metadatum3].map(&:app_name)) } + end + end end end diff --git a/spec/models/packages/package_file_spec.rb b/spec/models/packages/package_file_spec.rb index ebb10e991ad..9cf998a0639 100644 --- a/spec/models/packages/package_file_spec.rb +++ b/spec/models/packages/package_file_spec.rb @@ -62,6 +62,21 @@ RSpec.describe Packages::PackageFile, type: :model do end end + describe '.for_rubygem_with_file_name' do + let_it_be(:project) { create(:project) } + let_it_be(:non_ruby_package) { create(:nuget_package, project: project, package_type: :nuget) } + let_it_be(:ruby_package) { create(:rubygems_package, project: project, package_type: :rubygems) } + let_it_be(:file_name) { 'other.gem' } + + let_it_be(:non_ruby_file) { create(:package_file, :nuget, package: non_ruby_package, file_name: file_name) } + let_it_be(:gem_file1) { create(:package_file, :gem, package: ruby_package) } + let_it_be(:gem_file2) { create(:package_file, :gem, package: ruby_package, file_name: file_name) } + + it 'returns the matching gem file only for ruby packages' do + expect(described_class.for_rubygem_with_file_name(project, file_name)).to contain_exactly(gem_file2) + end + end + describe '#update_file_store callback' do let_it_be(:package_file) { build(:package_file, :nuget, size: nil) } diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb index 6c55d37b95f..82997acee3f 100644 --- a/spec/models/packages/package_spec.rb +++ b/spec/models/packages/package_spec.rb @@ -22,6 +22,14 @@ RSpec.describe Packages::Package, type: :model do it { is_expected.to have_one(:rubygems_metadatum).inverse_of(:package) } end + describe '.with_debian_codename' do + let_it_be(:publication) { create(:debian_publication) } + + subject { described_class.with_debian_codename(publication.distribution.codename).to_a } + + it { is_expected.to contain_exactly(publication.package) } + end + describe '.with_composer_target' do let!(:package1) { create(:composer_package, :with_metadatum, sha: '123') } let!(:package2) { create(:composer_package, :with_metadatum, sha: '123') } @@ -162,6 +170,18 @@ RSpec.describe Packages::Package, type: :model do it { is_expected.not_to allow_value('../../../my_package').for(:name) } it { is_expected.not_to allow_value('%2e%2e%2fmy_package').for(:name) } end + + context 'npm package' do + subject { build_stubbed(:npm_package) } + + it { is_expected.to allow_value("@group-1/package").for(:name) } + it { is_expected.to allow_value("@any-scope/package").for(:name) } + it { is_expected.to allow_value("unscoped-package").for(:name) } + it { is_expected.not_to allow_value("@inv@lid-scope/package").for(:name) } + it { is_expected.not_to allow_value("@scope/../../package").for(:name) } + it { is_expected.not_to allow_value("@scope%2e%2e%fpackage").for(:name) } + it { is_expected.not_to allow_value("@scope/sub/package").for(:name) } + end end describe '#version' do @@ -342,16 +362,6 @@ RSpec.describe Packages::Package, type: :model do end describe '#package_already_taken' do - context 'npm package' do - let!(:package) { create(:npm_package) } - - it 'will not allow a package of the same name' do - new_package = build(:npm_package, project: create(:project), name: package.name) - - expect(new_package).not_to be_valid - end - end - context 'maven package' do let!(:package) { create(:maven_package) } @@ -511,7 +521,7 @@ RSpec.describe Packages::Package, type: :model do describe '.without_nuget_temporary_name' do let!(:package1) { create(:nuget_package) } - let!(:package2) { create(:nuget_package, name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) } + let!(:package2) { create(:nuget_package, name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) } subject { described_class.without_nuget_temporary_name } @@ -530,7 +540,7 @@ RSpec.describe Packages::Package, type: :model do it { is_expected.to match_array([package1, package2, package3]) } context 'with temporary packages' do - let!(:package1) { create(:nuget_package, name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) } + let!(:package1) { create(:nuget_package, name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) } it { is_expected.to match_array([package2, package3]) } end @@ -803,4 +813,63 @@ RSpec.describe Packages::Package, type: :model do expect(package.package_settings).to eq(group.package_settings) end end + + describe '#sync_maven_metadata' do + let_it_be(:user) { create(:user) } + let_it_be(:package) { create(:maven_package) } + + subject { package.sync_maven_metadata(user) } + + shared_examples 'not enqueuing a sync worker job' do + it 'does not enqueue a sync worker job' do + expect(::Packages::Maven::Metadata::SyncWorker) + .not_to receive(:perform_async) + + subject + end + end + + it 'enqueues a sync worker job' do + expect(::Packages::Maven::Metadata::SyncWorker) + .to receive(:perform_async).with(user.id, package.project.id, package.name) + + subject + end + + context 'with no user' do + let(:user) { nil } + + it_behaves_like 'not enqueuing a sync worker job' + end + + context 'with a versionless maven package' do + let_it_be(:package) { create(:maven_package, version: nil) } + + it_behaves_like 'not enqueuing a sync worker job' + end + + context 'with a non maven package' do + let_it_be(:package) { create(:npm_package) } + + it_behaves_like 'not enqueuing a sync worker job' + end + end + + context 'destroying a composer package' do + let_it_be(:package_name) { 'composer-package-name' } + let_it_be(:json) { { 'name' => package_name } } + let_it_be(:project) { create(:project, :custom_repo, files: { 'composer.json' => json.to_json } ) } + let!(:package) { create(:composer_package, :with_metadatum, project: project, name: package_name, version: '1.0.0', json: json) } + + before do + Gitlab::Composer::Cache.new(project: project, name: package_name).execute + package.composer_metadatum.reload + end + + it 'schedule the update job' do + expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, package.composer_metadatum.version_cache_sha) + + package.destroy! + end + end end diff --git a/spec/models/pages/lookup_path_spec.rb b/spec/models/pages/lookup_path_spec.rb index 0a2b04f1a7c..9e65635da91 100644 --- a/spec/models/pages/lookup_path_spec.rb +++ b/spec/models/pages/lookup_path_spec.rb @@ -117,14 +117,6 @@ RSpec.describe Pages::LookupPath do end end - context 'when pages_serve_from_deployments feature flag is disabled' do - before do - stub_feature_flags(pages_serve_from_deployments: false) - end - - include_examples 'uses disk storage' - end - context 'when deployment were created during migration' do before do allow(deployment).to receive(:migrated?).and_return(true) diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb index 37402fa04c4..a56018f0fee 100644 --- a/spec/models/project_feature_spec.rb +++ b/spec/models/project_feature_spec.rb @@ -40,7 +40,7 @@ RSpec.describe ProjectFeature do end context 'public features' do - features = %w(issues wiki builds merge_requests snippets repository metrics_dashboard operations) + features = ProjectFeature::FEATURES - %i(pages) features.each do |feature| it "does not allow public access level for #{feature}" do @@ -187,4 +187,30 @@ RSpec.describe ProjectFeature do expect(described_class.required_minimum_access_level_for_private_project(:issues)).to eq(Gitlab::Access::GUEST) end end + + describe 'container_registry_access_level' do + context 'when the project is created with container_registry_enabled false' do + it 'creates project with DISABLED container_registry_access_level' do + project = create(:project, container_registry_enabled: false) + + expect(project.project_feature.container_registry_access_level).to eq(described_class::DISABLED) + end + end + + context 'when the project is created with container_registry_enabled true' do + it 'creates project with ENABLED container_registry_access_level' do + project = create(:project, container_registry_enabled: true) + + expect(project.project_feature.container_registry_access_level).to eq(described_class::ENABLED) + end + end + + context 'when the project is created with container_registry_enabled nil' do + it 'creates project with DISABLED container_registry_access_level' do + project = create(:project, container_registry_enabled: nil) + + expect(project.project_feature.container_registry_access_level).to eq(described_class::DISABLED) + end + end + end end diff --git a/spec/models/project_repository_storage_move_spec.rb b/spec/models/project_repository_storage_move_spec.rb index 88535f6dd6e..eb193a44680 100644 --- a/spec/models/project_repository_storage_move_spec.rb +++ b/spec/models/project_repository_storage_move_spec.rb @@ -9,7 +9,7 @@ RSpec.describe ProjectRepositoryStorageMove, type: :model do let(:container) { project } let(:repository_storage_factory_key) { :project_repository_storage_move } let(:error_key) { :project } - let(:repository_storage_worker) { ProjectUpdateRepositoryStorageWorker } + let(:repository_storage_worker) { Projects::UpdateRepositoryStorageWorker } end describe 'state transitions' do diff --git a/spec/models/project_services/discord_service_spec.rb b/spec/models/project_services/discord_service_spec.rb index d4bd08ddeb6..ffe0a36dcdc 100644 --- a/spec/models/project_services/discord_service_spec.rb +++ b/spec/models/project_services/discord_service_spec.rb @@ -6,7 +6,16 @@ RSpec.describe DiscordService do it_behaves_like "chat service", "Discord notifications" do let(:client) { Discordrb::Webhooks::Client } let(:client_arguments) { { url: webhook_url } } - let(:content_key) { :content } + let(:payload) do + { + embeds: [ + include( + author: include(name: be_present), + description: be_present + ) + ] + } + end end describe '#execute' do @@ -58,5 +67,16 @@ RSpec.describe DiscordService do expect { subject.execute(sample_data) }.to raise_error(ArgumentError, /is blocked/) end end + + context 'when the Discord request fails' do + before do + WebMock.stub_request(:post, webhook_url).to_return(status: 400) + end + + it 'logs an error and returns false' do + expect(subject).to receive(:log_error).with('400 Bad Request') + expect(subject.execute(sample_data)).to be(false) + end + end end end diff --git a/spec/models/project_services/hangouts_chat_service_spec.rb b/spec/models/project_services/hangouts_chat_service_spec.rb index 042e32439d1..9d3bd457fc8 100644 --- a/spec/models/project_services/hangouts_chat_service_spec.rb +++ b/spec/models/project_services/hangouts_chat_service_spec.rb @@ -6,6 +6,10 @@ RSpec.describe HangoutsChatService do it_behaves_like "chat service", "Hangouts Chat" do let(:client) { HangoutsChat::Sender } let(:client_arguments) { webhook_url } - let(:content_key) { :text } + let(:payload) do + { + text: be_present + } + end end end diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb index 78bd0e91208..3fc39fd3266 100644 --- a/spec/models/project_services/jira_service_spec.rb +++ b/spec/models/project_services/jira_service_spec.rb @@ -474,21 +474,32 @@ RSpec.describe JiraService do let(:custom_base_url) { 'http://custom_url' } shared_examples 'close_issue' do + let(:issue_key) { 'JIRA-123' } + let(:issue_url) { "#{url}/rest/api/2/issue/#{issue_key}" } + let(:transitions_url) { "#{issue_url}/transitions" } + let(:comment_url) { "#{issue_url}/comment" } + let(:remote_link_url) { "#{issue_url}/remotelink" } + let(:transitions) { nil } + + let(:issue_fields) do + { + id: issue_key, + self: issue_url, + transitions: transitions + } + end + + subject(:close_issue) do + jira_service.close_issue(resource, ExternalIssue.new(issue_key, project)) + end + before do - @jira_service = described_class.new - allow(@jira_service).to receive_messages( - project_id: project.id, - project: project, - url: 'http://jira.example.com', - username: 'gitlab_jira_username', - password: 'gitlab_jira_password', - jira_issue_transition_id: '999' - ) + allow(jira_service).to receive_messages(jira_issue_transition_id: '999') # These stubs are needed to test JiraService#close_issue. # We close the issue then do another request to API to check if it got closed. # Here is stubbed the API return with a closed and an opened issues. - open_issue = JIRA::Resource::Issue.new(@jira_service.client, attrs: { 'id' => 'JIRA-123' }) + open_issue = JIRA::Resource::Issue.new(jira_service.client, attrs: issue_fields.deep_stringify_keys) closed_issue = open_issue.dup allow(open_issue).to receive(:resolution).and_return(false) allow(closed_issue).to receive(:resolution).and_return(true) @@ -497,29 +508,22 @@ RSpec.describe JiraService do allow_any_instance_of(JIRA::Resource::Issue).to receive(:key).and_return('JIRA-123') allow(JIRA::Resource::Remotelink).to receive(:all).and_return([]) - @jira_service.save! - - project_issues_url = 'http://jira.example.com/rest/api/2/issue/JIRA-123' - @transitions_url = 'http://jira.example.com/rest/api/2/issue/JIRA-123/transitions' - @comment_url = 'http://jira.example.com/rest/api/2/issue/JIRA-123/comment' - @remote_link_url = 'http://jira.example.com/rest/api/2/issue/JIRA-123/remotelink' - - WebMock.stub_request(:get, project_issues_url).with(basic_auth: %w(gitlab_jira_username gitlab_jira_password)) - WebMock.stub_request(:post, @transitions_url).with(basic_auth: %w(gitlab_jira_username gitlab_jira_password)) - WebMock.stub_request(:post, @comment_url).with(basic_auth: %w(gitlab_jira_username gitlab_jira_password)) - WebMock.stub_request(:post, @remote_link_url).with(basic_auth: %w(gitlab_jira_username gitlab_jira_password)) + WebMock.stub_request(:get, issue_url).with(basic_auth: %w(jira-username jira-password)) + WebMock.stub_request(:post, transitions_url).with(basic_auth: %w(jira-username jira-password)) + WebMock.stub_request(:post, comment_url).with(basic_auth: %w(jira-username jira-password)) + WebMock.stub_request(:post, remote_link_url).with(basic_auth: %w(jira-username jira-password)) end let(:external_issue) { ExternalIssue.new('JIRA-123', project) } def close_issue - @jira_service.close_issue(resource, external_issue, current_user) + jira_service.close_issue(resource, external_issue, current_user) end it 'calls Jira API' do close_issue - expect(WebMock).to have_requested(:post, @comment_url).with( + expect(WebMock).to have_requested(:post, comment_url).with( body: /Issue solved with/ ).once end @@ -546,9 +550,9 @@ RSpec.describe JiraService do favicon_path = "http://localhost/assets/#{find_asset('favicon.png').digest_path}" # Creates comment - expect(WebMock).to have_requested(:post, @comment_url) + expect(WebMock).to have_requested(:post, comment_url) # Creates Remote Link in Jira issue fields - expect(WebMock).to have_requested(:post, @remote_link_url).with( + expect(WebMock).to have_requested(:post, remote_link_url).with( body: hash_including( GlobalID: 'GitLab', relationship: 'mentioned on', @@ -564,11 +568,11 @@ RSpec.describe JiraService do context 'when "comment_on_event_enabled" is set to false' do it 'creates Remote Link reference but does not create comment' do - allow(@jira_service).to receive_messages(comment_on_event_enabled: false) + allow(jira_service).to receive_messages(comment_on_event_enabled: false) close_issue - expect(WebMock).not_to have_requested(:post, @comment_url) - expect(WebMock).to have_requested(:post, @remote_link_url) + expect(WebMock).not_to have_requested(:post, comment_url) + expect(WebMock).to have_requested(:post, remote_link_url) end end @@ -589,7 +593,7 @@ RSpec.describe JiraService do close_issue - expect(WebMock).not_to have_requested(:post, @comment_url) + expect(WebMock).not_to have_requested(:post, comment_url) end end @@ -598,8 +602,8 @@ RSpec.describe JiraService do close_issue - expect(WebMock).not_to have_requested(:post, @comment_url) - expect(WebMock).not_to have_requested(:post, @remote_link_url) + expect(WebMock).not_to have_requested(:post, comment_url) + expect(WebMock).not_to have_requested(:post, remote_link_url) end it 'does not send comment or remote links to issues with unknown resolution' do @@ -607,8 +611,8 @@ RSpec.describe JiraService do close_issue - expect(WebMock).not_to have_requested(:post, @comment_url) - expect(WebMock).not_to have_requested(:post, @remote_link_url) + expect(WebMock).not_to have_requested(:post, comment_url) + expect(WebMock).not_to have_requested(:post, remote_link_url) end it 'references the GitLab commit' do @@ -616,7 +620,7 @@ RSpec.describe JiraService do close_issue - expect(WebMock).to have_requested(:post, @comment_url).with( + expect(WebMock).to have_requested(:post, comment_url).with( body: %r{#{custom_base_url}/#{project.full_path}/-/commit/#{commit_id}} ).once end @@ -631,18 +635,18 @@ RSpec.describe JiraService do close_issue - expect(WebMock).to have_requested(:post, @comment_url).with( + expect(WebMock).to have_requested(:post, comment_url).with( body: %r{#{Gitlab.config.gitlab.url}/#{project.full_path}/-/commit/#{commit_id}} ).once end it 'logs exception when transition id is not valid' do - allow(@jira_service).to receive(:log_error) - WebMock.stub_request(:post, @transitions_url).with(basic_auth: %w(gitlab_jira_username gitlab_jira_password)).and_raise("Bad Request") + allow(jira_service).to receive(:log_error) + WebMock.stub_request(:post, transitions_url).with(basic_auth: %w(jira-username jira-password)).and_raise("Bad Request") close_issue - expect(@jira_service).to have_received(:log_error).with( + expect(jira_service).to have_received(:log_error).with( "Issue transition failed", error: hash_including( exception_class: 'StandardError', @@ -655,34 +659,64 @@ RSpec.describe JiraService do it 'calls the api with jira_issue_transition_id' do close_issue - expect(WebMock).to have_requested(:post, @transitions_url).with( - body: /999/ + expect(WebMock).to have_requested(:post, transitions_url).with( + body: /"id":"999"/ ).once end - context 'when have multiple transition ids' do - it 'calls the api with transition ids separated by comma' do - allow(@jira_service).to receive_messages(jira_issue_transition_id: '1,2,3') + context 'when using multiple transition ids' do + before do + allow(jira_service).to receive_messages(jira_issue_transition_id: '1,2,3') + end + it 'calls the api with transition ids separated by comma' do close_issue 1.upto(3) do |transition_id| - expect(WebMock).to have_requested(:post, @transitions_url).with( - body: /#{transition_id}/ + expect(WebMock).to have_requested(:post, transitions_url).with( + body: /"id":"#{transition_id}"/ ).once end + + expect(WebMock).to have_requested(:post, comment_url) end it 'calls the api with transition ids separated by semicolon' do - allow(@jira_service).to receive_messages(jira_issue_transition_id: '1;2;3') + allow(jira_service).to receive_messages(jira_issue_transition_id: '1;2;3') close_issue 1.upto(3) do |transition_id| - expect(WebMock).to have_requested(:post, @transitions_url).with( - body: /#{transition_id}/ + expect(WebMock).to have_requested(:post, transitions_url).with( + body: /"id":"#{transition_id}"/ ).once end + + expect(WebMock).to have_requested(:post, comment_url) + end + + context 'when a transition fails' do + before do + WebMock.stub_request(:post, transitions_url).with(basic_auth: %w(jira-username jira-password)).to_return do |request| + { status: request.body.include?('"id":"2"') ? 500 : 200 } + end + end + + it 'stops the sequence' do + close_issue + + 1.upto(2) do |transition_id| + expect(WebMock).to have_requested(:post, transitions_url).with( + body: /"id":"#{transition_id}"/ + ) + end + + expect(WebMock).not_to have_requested(:post, transitions_url).with( + body: /"id":"3"/ + ) + + expect(WebMock).not_to have_requested(:post, comment_url) + end end end end diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb index ea63406e615..366c3f68e1d 100644 --- a/spec/models/project_services/prometheus_service_spec.rb +++ b/spec/models/project_services/prometheus_service_spec.rb @@ -511,20 +511,23 @@ RSpec.describe PrometheusService, :use_clean_rails_memory_store_caching, :snowpl type: 'checkbox', name: 'manual_configuration', title: s_('PrometheusService|Active'), + help: s_('PrometheusService|Select this checkbox to override the auto configuration settings with your own settings.'), required: true }, { type: 'text', name: 'api_url', title: 'API URL', - placeholder: s_('PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/'), + placeholder: s_('PrometheusService|https://prometheus.example.com/'), + help: s_('PrometheusService|The Prometheus API base URL.'), required: true }, { type: 'text', name: 'google_iap_audience_client_id', title: 'Google IAP Audience Client ID', - placeholder: s_('PrometheusService|Client ID of the IAP secured resource (looks like IAP_CLIENT_ID.apps.googleusercontent.com)'), + placeholder: s_('PrometheusService|IAP_CLIENT_ID.apps.googleusercontent.com'), + help: s_('PrometheusService|PrometheusService|The ID of the IAP-secured resource.'), autocomplete: 'off', required: false }, @@ -532,7 +535,8 @@ RSpec.describe PrometheusService, :use_clean_rails_memory_store_caching, :snowpl type: 'textarea', name: 'google_iap_service_account_json', title: 'Google IAP Service Account JSON', - placeholder: s_('PrometheusService|Contents of the credentials.json file of your service account, like: { "type": "service_account", "project_id": ... }'), + placeholder: s_('PrometheusService|{ "type": "service_account", "project_id": ... }'), + help: s_('PrometheusService|The contents of the credentials.json file of your service account.'), required: false } ] diff --git a/spec/models/project_services/slack_service_spec.rb b/spec/models/project_services/slack_service_spec.rb index 0b35b9e7b30..aa5d92e5c61 100644 --- a/spec/models/project_services/slack_service_spec.rb +++ b/spec/models/project_services/slack_service_spec.rb @@ -4,4 +4,116 @@ require 'spec_helper' RSpec.describe SlackService do it_behaves_like "slack or mattermost notifications", 'Slack' + + describe '#execute' do + before do + stub_request(:post, "https://slack.service.url/") + end + + let_it_be(:slack_service) { create(:slack_service, branches_to_be_notified: 'all') } + + it 'uses only known events', :aggregate_failures do + described_class::SUPPORTED_EVENTS_FOR_USAGE_LOG.each do |action| + expect(Gitlab::UsageDataCounters::HLLRedisCounter.known_event?("i_ecosystem_slack_service_#{action}_notification")).to be true + end + end + + context 'hook data includes a user object' do + let_it_be(:user) { create_default(:user) } + let_it_be(:project) { create_default(:project, :repository, :wiki_repo) } + + shared_examples 'increases the usage data counter' do |event_name| + it 'increases the usage data counter' do + expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(event_name, values: user.id).and_call_original + + slack_service.execute(data) + end + end + + context 'event is not supported for usage log' do + let_it_be(:pipeline) { create(:ci_pipeline) } + let(:data) { Gitlab::DataBuilder::Pipeline.build(pipeline) } + + it 'does not increase the usage data counter' do + expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event).with('i_ecosystem_slack_service_pipeline_notification', values: user.id) + + slack_service.execute(data) + end + end + + context 'issue notification' do + let_it_be(:issue) { create(:issue) } + let(:data) { issue.to_hook_data(user) } + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_issue_notification' + end + + context 'push notification' do + let(:data) { Gitlab::DataBuilder::Push.build_sample(project, user) } + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_push_notification' + end + + context 'deployment notification' do + let_it_be(:deployment) { create(:deployment, user: user) } + let(:data) { Gitlab::DataBuilder::Deployment.build(deployment) } + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_deployment_notification' + end + + context 'wiki_page notification' do + let_it_be(:wiki_page) { create(:wiki_page, wiki: project.wiki, message: 'user created page: Awesome wiki_page') } + let(:data) { Gitlab::DataBuilder::WikiPage.build(wiki_page, user, 'create') } + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_wiki_page_notification' + end + + context 'merge_request notification' do + let_it_be(:merge_request) { create(:merge_request) } + let(:data) { merge_request.to_hook_data(user) } + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_merge_request_notification' + end + + context 'note notification' do + let_it_be(:issue_note) { create(:note_on_issue, note: 'issue note') } + let(:data) { Gitlab::DataBuilder::Note.build(issue_note, user) } + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_note_notification' + end + + context 'tag_push notification' do + let(:oldrev) { Gitlab::Git::BLANK_SHA } + let(:newrev) { '8a2a6eb295bb170b34c24c76c49ed0e9b2eaf34b' } # gitlab-test: git rev-parse refs/tags/v1.1.0 + let(:ref) { 'refs/tags/v1.1.0' } + let(:data) { Git::TagHooksService.new(project, user, change: { oldrev: oldrev, newrev: newrev, ref: ref }).send(:push_data) } + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_tag_push_notification' + end + + context 'confidential note notification' do + let_it_be(:confidential_issue_note) { create(:note_on_issue, note: 'issue note', confidential: true) } + let(:data) { Gitlab::DataBuilder::Note.build(confidential_issue_note, user) } + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_confidential_note_notification' + end + + context 'confidential issue notification' do + let_it_be(:issue) { create(:issue, confidential: true) } + let(:data) { issue.to_hook_data(user) } + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_confidential_issue_notification' + end + end + + context 'hook data does not include a user' do + let(:data) { Gitlab::DataBuilder::Pipeline.build(create(:ci_pipeline)) } + + it 'does not increase the usage data counter' do + expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event) + + slack_service.execute(data) + end + end + end end diff --git a/spec/models/project_services/unify_circuit_service_spec.rb b/spec/models/project_services/unify_circuit_service_spec.rb index 73702aa8471..0c749322e07 100644 --- a/spec/models/project_services/unify_circuit_service_spec.rb +++ b/spec/models/project_services/unify_circuit_service_spec.rb @@ -5,6 +5,12 @@ require "spec_helper" RSpec.describe UnifyCircuitService do it_behaves_like "chat service", "Unify Circuit" do let(:client_arguments) { webhook_url } - let(:content_key) { :subject } + let(:payload) do + { + subject: project.full_name, + text: be_present, + markdown: true + } + end end end diff --git a/spec/models/project_services/webex_teams_service_spec.rb b/spec/models/project_services/webex_teams_service_spec.rb index bd73d0c93b8..ed63f5bc48c 100644 --- a/spec/models/project_services/webex_teams_service_spec.rb +++ b/spec/models/project_services/webex_teams_service_spec.rb @@ -5,6 +5,10 @@ require "spec_helper" RSpec.describe WebexTeamsService do it_behaves_like "chat service", "Webex Teams" do let(:client_arguments) { webhook_url } - let(:content_key) { :markdown } + let(:payload) do + { + markdown: be_present + } + end end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index fd7975bf65d..1cee494989d 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Project, factory_default: :keep do include ExternalAuthorizationServiceHelpers using RSpec::Parameterized::TableSyntax - let_it_be(:namespace) { create_default(:namespace) } + let_it_be(:namespace) { create_default(:namespace).freeze } it_behaves_like 'having unique enum values' @@ -145,7 +145,7 @@ RSpec.describe Project, factory_default: :keep do end it_behaves_like 'model with wiki' do - let_it_be(:container) { create(:project, :wiki_repo) } + let_it_be(:container) { create(:project, :wiki_repo, namespace: create(:group)) } let(:container_without_wiki) { create(:project) } end @@ -1599,7 +1599,7 @@ RSpec.describe Project, factory_default: :keep do end end - describe '#any_runners?' do + describe '#any_active_runners?' do context 'shared runners' do let(:project) { create(:project, shared_runners_enabled: shared_runners_enabled) } let(:specific_runner) { create(:ci_runner, :project, projects: [project]) } @@ -1609,31 +1609,31 @@ RSpec.describe Project, factory_default: :keep do let(:shared_runners_enabled) { false } it 'has no runners available' do - expect(project.any_runners?).to be_falsey + expect(project.any_active_runners?).to be_falsey end it 'has a specific runner' do specific_runner - expect(project.any_runners?).to be_truthy + expect(project.any_active_runners?).to be_truthy end it 'has a shared runner, but they are prohibited to use' do shared_runner - expect(project.any_runners?).to be_falsey + expect(project.any_active_runners?).to be_falsey end it 'checks the presence of specific runner' do specific_runner - expect(project.any_runners? { |runner| runner == specific_runner }).to be_truthy + expect(project.any_active_runners? { |runner| runner == specific_runner }).to be_truthy end it 'returns false if match cannot be found' do specific_runner - expect(project.any_runners? { false }).to be_falsey + expect(project.any_active_runners? { false }).to be_falsey end end @@ -1643,19 +1643,19 @@ RSpec.describe Project, factory_default: :keep do it 'has a shared runner' do shared_runner - expect(project.any_runners?).to be_truthy + expect(project.any_active_runners?).to be_truthy end it 'checks the presence of shared runner' do shared_runner - expect(project.any_runners? { |runner| runner == shared_runner }).to be_truthy + expect(project.any_active_runners? { |runner| runner == shared_runner }).to be_truthy end it 'returns false if match cannot be found' do shared_runner - expect(project.any_runners? { false }).to be_falsey + expect(project.any_active_runners? { false }).to be_falsey end end end @@ -1669,13 +1669,13 @@ RSpec.describe Project, factory_default: :keep do let(:group_runners_enabled) { false } it 'has no runners available' do - expect(project.any_runners?).to be_falsey + expect(project.any_active_runners?).to be_falsey end it 'has a group runner, but they are prohibited to use' do group_runner - expect(project.any_runners?).to be_falsey + expect(project.any_active_runners?).to be_falsey end end @@ -1685,19 +1685,19 @@ RSpec.describe Project, factory_default: :keep do it 'has a group runner' do group_runner - expect(project.any_runners?).to be_truthy + expect(project.any_active_runners?).to be_truthy end it 'checks the presence of group runner' do group_runner - expect(project.any_runners? { |runner| runner == group_runner }).to be_truthy + expect(project.any_active_runners? { |runner| runner == group_runner }).to be_truthy end it 'returns false if match cannot be found' do group_runner - expect(project.any_runners? { false }).to be_falsey + expect(project.any_active_runners? { false }).to be_falsey end end end @@ -1799,7 +1799,8 @@ RSpec.describe Project, factory_default: :keep do describe '#default_branch_protected?' do using RSpec::Parameterized::TableSyntax - let_it_be(:project) { create(:project) } + let_it_be(:namespace) { create(:namespace) } + let_it_be(:project) { create(:project, namespace: namespace) } subject { project.default_branch_protected? } @@ -2201,6 +2202,44 @@ RSpec.describe Project, factory_default: :keep do end end + describe '#set_container_registry_access_level' do + let_it_be_with_reload(:project) { create(:project) } + + it 'updates project_feature', :aggregate_failures do + # Simulate an existing project that has container_registry enabled + project.update_column(:container_registry_enabled, true) + project.project_feature.update_column(:container_registry_access_level, ProjectFeature::DISABLED) + + expect(project.container_registry_enabled).to eq(true) + expect(project.project_feature.container_registry_access_level).to eq(ProjectFeature::DISABLED) + + project.update!(container_registry_enabled: false) + + expect(project.container_registry_enabled).to eq(false) + expect(project.project_feature.container_registry_access_level).to eq(ProjectFeature::DISABLED) + + project.update!(container_registry_enabled: true) + + expect(project.container_registry_enabled).to eq(true) + expect(project.project_feature.container_registry_access_level).to eq(ProjectFeature::ENABLED) + end + + it 'rollsback both projects and project_features row in case of error', :aggregate_failures do + project.update_column(:container_registry_enabled, true) + project.project_feature.update_column(:container_registry_access_level, ProjectFeature::DISABLED) + + expect(project.container_registry_enabled).to eq(true) + expect(project.project_feature.container_registry_access_level).to eq(ProjectFeature::DISABLED) + + allow(project).to receive(:valid?).and_return(false) + + expect { project.update!(container_registry_enabled: false) }.to raise_error(ActiveRecord::RecordInvalid) + + expect(project.reload.container_registry_enabled).to eq(true) + expect(project.project_feature.reload.container_registry_access_level).to eq(ProjectFeature::DISABLED) + end + end + describe '#has_container_registry_tags?' do let(:project) { build(:project) } @@ -2802,7 +2841,8 @@ RSpec.describe Project, factory_default: :keep do end describe '#emails_disabled?' do - let(:project) { build(:project, emails_disabled: false) } + let_it_be(:namespace) { create(:namespace) } + let(:project) { build(:project, namespace: namespace, emails_disabled: false) } context 'emails disabled in group' do it 'returns true' do @@ -2830,7 +2870,8 @@ RSpec.describe Project, factory_default: :keep do end describe '#lfs_enabled?' do - let(:project) { build(:project) } + let(:namespace) { create(:namespace) } + let(:project) { build(:project, namespace: namespace) } shared_examples 'project overrides group' do it 'returns true when enabled in project' do @@ -4463,7 +4504,11 @@ RSpec.describe Project, factory_default: :keep do subject { project.predefined_project_variables.to_runner_variables } specify do - expect(subject).to include({ key: 'CI_PROJECT_CONFIG_PATH', value: Ci::Pipeline::DEFAULT_CONFIG_PATH, public: true, masked: false }) + expect(subject).to include + [ + { key: 'CI_PROJECT_CONFIG_PATH', value: Ci::Pipeline::DEFAULT_CONFIG_PATH, public: true, masked: false }, + { key: 'CI_CONFIG_PATH', value: Ci::Pipeline::DEFAULT_CONFIG_PATH, public: true, masked: false } + ] end context 'when ci config path is overridden' do @@ -4471,7 +4516,41 @@ RSpec.describe Project, factory_default: :keep do project.update!(ci_config_path: 'random.yml') end - it { expect(subject).to include({ key: 'CI_PROJECT_CONFIG_PATH', value: 'random.yml', public: true, masked: false }) } + it do + expect(subject).to include + [ + { key: 'CI_PROJECT_CONFIG_PATH', value: 'random.yml', public: true, masked: false }, + { key: 'CI_CONFIG_PATH', value: 'random.yml', public: true, masked: false } + ] + end + end + end + + describe '#dependency_proxy_variables' do + let_it_be(:namespace) { create(:namespace, path: 'NameWithUPPERcaseLetters') } + let_it_be(:project) { create(:project, :repository, namespace: namespace) } + + subject { project.dependency_proxy_variables.to_runner_variables } + + context 'when dependency_proxy is enabled' do + before do + stub_config(dependency_proxy: { enabled: true }) + end + + it 'contains the downcased name' do + expect(subject).to include({ key: 'CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX', + value: "#{Gitlab.host_with_port}/namewithuppercaseletters#{DependencyProxy::URL_SUFFIX}", + public: true, + masked: false }) + end + end + + context 'when dependency_proxy is disabled' do + before do + stub_config(dependency_proxy: { enabled: false }) + end + + it { expect(subject).to be_empty } end end @@ -4877,7 +4956,8 @@ RSpec.describe Project, factory_default: :keep do end context 'branch protection' do - let(:project) { create(:project, :repository) } + let_it_be(:namespace) { create(:namespace) } + let(:project) { create(:project, :repository, namespace: namespace) } before do create(:import_state, :started, project: project) diff --git a/spec/models/projects/repository_storage_move_spec.rb b/spec/models/projects/repository_storage_move_spec.rb new file mode 100644 index 00000000000..ab0ad81f77a --- /dev/null +++ b/spec/models/projects/repository_storage_move_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::RepositoryStorageMove, type: :model do + let_it_be_with_refind(:project) { create(:project) } + + it_behaves_like 'handles repository moves' do + let(:container) { project } + let(:repository_storage_factory_key) { :project_repository_storage_move } + let(:error_key) { :project } + let(:repository_storage_worker) { Projects::UpdateRepositoryStorageWorker } + end + + describe 'state transitions' do + let(:storage) { 'test_second_storage' } + + before do + stub_storage_settings(storage => { 'path' => 'tmp/tests/extra_storage' }) + end + + context 'when started' do + subject(:storage_move) { create(:project_repository_storage_move, :started, container: project, destination_storage_name: storage) } + + context 'and transits to replicated' do + it 'sets the repository storage and marks the container as writable' do + storage_move.finish_replication! + + expect(project.repository_storage).to eq(storage) + expect(project).not_to be_repository_read_only + end + end + end + end +end diff --git a/spec/models/prometheus_alert_event_spec.rb b/spec/models/prometheus_alert_event_spec.rb index 913ca7db0be..6bff549bc4b 100644 --- a/spec/models/prometheus_alert_event_spec.rb +++ b/spec/models/prometheus_alert_event_spec.rb @@ -52,7 +52,7 @@ RSpec.describe PrometheusAlertEvent do let(:started_at) { Time.current } context 'when status is none' do - subject { build(:prometheus_alert_event, :none) } + subject { build(:prometheus_alert_event, status: nil, started_at: nil) } it 'fires an event' do result = subject.fire(started_at) diff --git a/spec/models/protected_branch_spec.rb b/spec/models/protected_branch_spec.rb index a89f8778780..a173ab48f17 100644 --- a/spec/models/protected_branch_spec.rb +++ b/spec/models/protected_branch_spec.rb @@ -207,6 +207,28 @@ RSpec.describe ProtectedBranch do end end + describe "#allow_force_push?" do + context "when the attr allow_force_push is true" do + let(:subject_branch) { create(:protected_branch, allow_force_push: true, name: "foo") } + + it "returns true" do + project = subject_branch.project + + expect(described_class.allow_force_push?(project, "foo")).to eq(true) + end + end + + context "when the attr allow_force_push is false" do + let(:subject_branch) { create(:protected_branch, allow_force_push: false, name: "foo") } + + it "returns false" do + project = subject_branch.project + + expect(described_class.allow_force_push?(project, "foo")).to eq(false) + end + end + end + describe '#any_protected?' do context 'existing project' do let(:project) { create(:project, :repository) } diff --git a/spec/models/snippet_repository_spec.rb b/spec/models/snippet_repository_spec.rb index cdbc1feefce..11196f06529 100644 --- a/spec/models/snippet_repository_spec.rb +++ b/spec/models/snippet_repository_spec.rb @@ -286,6 +286,7 @@ RSpec.describe SnippetRepository do context 'with git errors' do it_behaves_like 'snippet repository with git errors', 'invalid://path/here', described_class::InvalidPathError + it_behaves_like 'snippet repository with git errors', '.git/hooks/pre-commit', described_class::InvalidPathError it_behaves_like 'snippet repository with git errors', '../../path/traversal/here', described_class::InvalidPathError it_behaves_like 'snippet repository with git errors', 'README', described_class::CommitError diff --git a/spec/models/snippet_repository_storage_move_spec.rb b/spec/models/snippet_repository_storage_move_spec.rb index 357951f8859..f5ad837fb36 100644 --- a/spec/models/snippet_repository_storage_move_spec.rb +++ b/spec/models/snippet_repository_storage_move_spec.rb @@ -8,6 +8,6 @@ RSpec.describe SnippetRepositoryStorageMove, type: :model do let(:repository_storage_factory_key) { :snippet_repository_storage_move } let(:error_key) { :snippet } - let(:repository_storage_worker) { SnippetUpdateRepositoryStorageWorker } + let(:repository_storage_worker) { Snippets::UpdateRepositoryStorageWorker } end end diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb index 623767d19e0..09f9cf8e222 100644 --- a/spec/models/snippet_spec.rb +++ b/spec/models/snippet_spec.rb @@ -21,7 +21,7 @@ RSpec.describe Snippet do it { is_expected.to have_many(:user_mentions).class_name("SnippetUserMention") } it { is_expected.to have_one(:snippet_repository) } it { is_expected.to have_one(:statistics).class_name('SnippetStatistics').dependent(:destroy) } - it { is_expected.to have_many(:repository_storage_moves).class_name('SnippetRepositoryStorageMove').inverse_of(:container) } + it { is_expected.to have_many(:repository_storage_moves).class_name('Snippets::RepositoryStorageMove').inverse_of(:container) } end describe 'validation' do @@ -496,6 +496,16 @@ RSpec.describe Snippet do it 'returns array of blobs' do expect(snippet.blobs).to all(be_a(Blob)) end + + context 'when file does not exist' do + it 'removes nil values from the blobs array' do + allow(snippet).to receive(:list_files).and_return(%w(LICENSE non_existent_snippet_file)) + + blobs = snippet.blobs + expect(blobs.count).to eq 1 + expect(blobs.first.name).to eq 'LICENSE' + end + end end end diff --git a/spec/models/snippets/repository_storage_move_spec.rb b/spec/models/snippets/repository_storage_move_spec.rb new file mode 100644 index 00000000000..ed518faf6ff --- /dev/null +++ b/spec/models/snippets/repository_storage_move_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Snippets::RepositoryStorageMove, type: :model do + it_behaves_like 'handles repository moves' do + let_it_be_with_refind(:container) { create(:snippet) } + + let(:repository_storage_factory_key) { :snippet_repository_storage_move } + let(:error_key) { :snippet } + let(:repository_storage_worker) { Snippets::UpdateRepositoryStorageWorker } + end +end diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb index a9c4c6680cd..855b1b0f3f7 100644 --- a/spec/models/todo_spec.rb +++ b/spec/models/todo_spec.rb @@ -363,23 +363,6 @@ RSpec.describe Todo do end end - describe '.for_ids' do - it 'returns the expected todos' do - todo1 = create(:todo) - todo2 = create(:todo) - todo3 = create(:todo) - create(:todo) - - expect(described_class.for_ids([todo2.id, todo1.id, todo3.id])).to contain_exactly(todo1, todo2, todo3) - end - - it 'returns an empty collection when no ids are given' do - create(:todo) - - expect(described_class.for_ids([])).to be_empty - end - end - describe '.for_user' do it 'returns the expected todos' do user1 = create(:user) diff --git a/spec/models/upload_spec.rb b/spec/models/upload_spec.rb index 18388b4cd83..6bac5e31435 100644 --- a/spec/models/upload_spec.rb +++ b/spec/models/upload_spec.rb @@ -221,7 +221,7 @@ RSpec.describe Upload do it 'does not send a message to Sentry' do upload = described_class.new(path: "#{__FILE__}-nope", store: ObjectStorage::Store::LOCAL) - expect(Raven).not_to receive(:capture_message) + expect(Gitlab::ErrorTracking).not_to receive(:track_exception) upload.exist? end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 860c015e166..5f2842c9d16 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -41,6 +41,9 @@ RSpec.describe User do it { is_expected.to delegate_method(:show_whitespace_in_diffs).to(:user_preference) } it { is_expected.to delegate_method(:show_whitespace_in_diffs=).to(:user_preference).with_arguments(:args) } + it { is_expected.to delegate_method(:view_diffs_file_by_file).to(:user_preference) } + it { is_expected.to delegate_method(:view_diffs_file_by_file=).to(:user_preference).with_arguments(:args) } + it { is_expected.to delegate_method(:tab_width).to(:user_preference) } it { is_expected.to delegate_method(:tab_width=).to(:user_preference).with_arguments(:args) } @@ -59,6 +62,9 @@ RSpec.describe User do it { is_expected.to delegate_method(:experience_level).to(:user_preference) } it { is_expected.to delegate_method(:experience_level=).to(:user_preference).with_arguments(:args) } + it { is_expected.to delegate_method(:markdown_surround_selection).to(:user_preference) } + it { is_expected.to delegate_method(:markdown_surround_selection=).to(:user_preference).with_arguments(:args) } + it { is_expected.to delegate_method(:job_title).to(:user_detail).allow_nil } it { is_expected.to delegate_method(:job_title=).to(:user_detail).with_arguments(:args).allow_nil } @@ -101,6 +107,7 @@ RSpec.describe User do it { is_expected.to have_many(:reviews).inverse_of(:author) } it { is_expected.to have_many(:merge_request_assignees).inverse_of(:assignee) } it { is_expected.to have_many(:merge_request_reviewers).inverse_of(:reviewer) } + it { is_expected.to have_many(:created_custom_emoji).inverse_of(:creator) } describe "#user_detail" do it 'does not persist `user_detail` by default' do @@ -380,11 +387,11 @@ RSpec.describe User do it { is_expected.not_to allow_value(-1).for(:projects_limit) } it { is_expected.not_to allow_value(Gitlab::Database::MAX_INT_VALUE + 1).for(:projects_limit) } - it_behaves_like 'an object with email-formated attributes', :email do + it_behaves_like 'an object with email-formatted attributes', :email do subject { build(:user) } end - it_behaves_like 'an object with RFC3696 compliant email-formated attributes', :public_email, :notification_email do + it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :public_email, :notification_email do subject { create(:user).tap { |user| user.emails << build(:email, email: email_value, confirmed_at: Time.current) } } end @@ -1050,7 +1057,7 @@ RSpec.describe User do let(:user) { create(:user) } let(:external_user) { create(:user, external: true) } - it "sets other properties aswell" do + it "sets other properties as well" do expect(external_user.can_create_team).to be_falsey expect(external_user.can_create_group).to be_falsey expect(external_user.projects_limit).to be 0 @@ -1061,7 +1068,7 @@ RSpec.describe User do let(:user) { create(:user) } let(:secondary) { create(:email, :confirmed, email: 'secondary@example.com', user: user) } - it 'allows a verfied secondary email to be used as the primary without needing reconfirmation' do + it 'allows a verified secondary email to be used as the primary without needing reconfirmation' do user.update!(email: secondary.email) user.reload expect(user.email).to eq secondary.email @@ -1827,7 +1834,7 @@ RSpec.describe User do end describe '.instance_access_request_approvers_to_be_notified' do - let_it_be(:admin_list) { create_list(:user, 12, :admin, :with_sign_ins) } + let_it_be(:admin_issue_board_list) { create_list(:user, 12, :admin, :with_sign_ins) } it 'returns up to the ten most recently active instance admins' do active_admins_in_recent_sign_in_desc_order = User.admins.active.order_recent_sign_in.limit(10) @@ -2492,6 +2499,38 @@ RSpec.describe User do end end + describe "#clear_avatar_caches" do + let(:user) { create(:user) } + + context "when :avatar_cache_for_email flag is enabled" do + before do + stub_feature_flags(avatar_cache_for_email: true) + end + + it "clears the avatar cache when saving" do + allow(user).to receive(:avatar_changed?).and_return(true) + + expect(Gitlab::AvatarCache).to receive(:delete_by_email).with(*user.verified_emails) + + user.update(avatar: fixture_file_upload('spec/fixtures/dk.png')) + end + end + + context "when :avatar_cache_for_email flag is disabled" do + before do + stub_feature_flags(avatar_cache_for_email: false) + end + + it "doesn't attempt to clear the avatar cache" do + allow(user).to receive(:avatar_changed?).and_return(true) + + expect(Gitlab::AvatarCache).not_to receive(:delete_by_email) + + user.update(avatar: fixture_file_upload('spec/fixtures/dk.png')) + end + end + end + describe '#accept_pending_invitations!' do let(:user) { create(:user, email: 'user@email.com') } let!(:project_member_invite) { create(:project_member, :invited, invite_email: user.email) } @@ -3227,23 +3266,8 @@ RSpec.describe User do create(:group_group_link, shared_group: private_group, shared_with_group: other_group) end - context 'when shared_group_membership_auth is enabled' do - before do - stub_feature_flags(shared_group_membership_auth: user) - end - - it { is_expected.to include shared_group } - it { is_expected.not_to include other_group } - end - - context 'when shared_group_membership_auth is disabled' do - before do - stub_feature_flags(shared_group_membership_auth: false) - end - - it { is_expected.not_to include shared_group } - it { is_expected.not_to include other_group } - end + it { is_expected.to include shared_group } + it { is_expected.not_to include other_group } end end @@ -3937,6 +3961,37 @@ RSpec.describe User do end end + describe '#can_admin_all_resources?', :request_store do + it 'returns false for regular user' do + user = build_stubbed(:user) + + expect(user.can_admin_all_resources?).to be_falsy + end + + context 'for admin user' do + include_context 'custom session' + + let(:user) { build_stubbed(:user, :admin) } + + context 'when admin mode is disabled' do + it 'returns false' do + expect(user.can_admin_all_resources?).to be_falsy + end + end + + context 'when admin mode is enabled' do + before do + Gitlab::Auth::CurrentUserMode.new(user).request_admin_mode! + Gitlab::Auth::CurrentUserMode.new(user).enable_admin_mode!(password: user.password) + end + + it 'returns true' do + expect(user.can_admin_all_resources?).to be_truthy + end + end + end + end + describe '.ghost' do it "creates a ghost user if one isn't already present" do ghost = described_class.ghost @@ -5370,6 +5425,40 @@ RSpec.describe User do end end + describe 'can_trigger_notifications?' do + context 'when user is not confirmed' do + let_it_be(:user) { create(:user, :unconfirmed) } + + it 'returns false' do + expect(user.can_trigger_notifications?).to be(false) + end + end + + context 'when user is blocked' do + let_it_be(:user) { create(:user, :blocked) } + + it 'returns false' do + expect(user.can_trigger_notifications?).to be(false) + end + end + + context 'when user is a ghost' do + let_it_be(:user) { create(:user, :ghost) } + + it 'returns false' do + expect(user.can_trigger_notifications?).to be(false) + end + end + + context 'when user is confirmed and neither blocked or a ghost' do + let_it_be(:user) { create(:user) } + + it 'returns true' do + expect(user.can_trigger_notifications?).to be(true) + end + end + end + context 'bot users' do shared_examples 'bot users' do |bot_type| it 'creates the user if it does not exist' do @@ -5412,4 +5501,89 @@ RSpec.describe User do it_behaves_like 'bot user avatars', :support_bot, 'support-bot.png' it_behaves_like 'bot user avatars', :security_bot, 'security-bot.png' end + + describe '#confirmation_required_on_sign_in?' do + subject { user.confirmation_required_on_sign_in? } + + context 'when user is confirmed' do + let(:user) { build_stubbed(:user) } + + it 'is falsey' do + expect(user.confirmed?).to be_truthy + expect(subject).to be_falsey + end + end + + context 'when user is not confirmed' do + let_it_be(:user) { build_stubbed(:user, :unconfirmed, confirmation_sent_at: Time.current) } + + it 'is truthy when soft_email_confirmation feature is disabled' do + stub_feature_flags(soft_email_confirmation: false) + expect(subject).to be_truthy + end + + context 'when soft_email_confirmation feature is enabled' do + before do + stub_feature_flags(soft_email_confirmation: true) + end + + it 'is falsey when confirmation period is valid' do + expect(subject).to be_falsey + end + + it 'is truthy when confirmation period is expired' do + travel_to(User.allow_unconfirmed_access_for.from_now + 1.day) do + expect(subject).to be_truthy + end + end + + context 'when user has no confirmation email sent' do + let(:user) { build(:user, :unconfirmed, confirmation_sent_at: nil) } + + it 'is truthy' do + expect(subject).to be_truthy + end + end + end + end + end + + describe '#find_or_initialize_callout' do + subject(:find_or_initialize_callout) { user.find_or_initialize_callout(feature_name) } + + let(:user) { create(:user) } + let(:feature_name) { UserCallout.feature_names.each_key.first } + + context 'when callout exists' do + let!(:callout) { create(:user_callout, user: user, feature_name: feature_name) } + + it 'returns existing callout' do + expect(find_or_initialize_callout).to eq(callout) + end + end + + context 'when callout does not exist' do + context 'when feature name is valid' do + it 'initializes a new callout' do + expect(find_or_initialize_callout).to be_a_new(UserCallout) + end + + it 'is valid' do + expect(find_or_initialize_callout).to be_valid + end + end + + context 'when feature name is not valid' do + let(:feature_name) { 'notvalid' } + + it 'initializes a new callout' do + expect(find_or_initialize_callout).to be_a_new(UserCallout) + end + + it 'is not valid' do + expect(find_or_initialize_callout).not_to be_valid + end + end + end + end end diff --git a/spec/policies/base_policy_spec.rb b/spec/policies/base_policy_spec.rb index 226660dc955..44ff909872d 100644 --- a/spec/policies/base_policy_spec.rb +++ b/spec/policies/base_policy_spec.rb @@ -73,10 +73,14 @@ RSpec.describe BasePolicy do end end - describe 'full private access' do + describe 'full private access: read_all_resources' do it_behaves_like 'admin only access', :read_all_resources end + describe 'full private access: admin_all_resources' do + it_behaves_like 'admin only access', :admin_all_resources + end + describe 'change_repository_storage' do it_behaves_like 'admin only access', :change_repository_storage end diff --git a/spec/policies/group_member_policy_spec.rb b/spec/policies/group_member_policy_spec.rb index 6099e4549b1..d283b0ffda5 100644 --- a/spec/policies/group_member_policy_spec.rb +++ b/spec/policies/group_member_policy_spec.rb @@ -90,6 +90,14 @@ RSpec.describe GroupMemberPolicy do specify { expect_allowed(:read_group) } end + context 'with one blocked owner' do + let(:owner) { create(:user, :blocked) } + let(:current_user) { owner } + + specify { expect_disallowed(*member_related_permissions) } + specify { expect_disallowed(:read_group) } + end + context 'with more than one owner' do let(:current_user) { owner } diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb index 7cded27e449..1794934dd20 100644 --- a/spec/policies/group_policy_spec.rb +++ b/spec/policies/group_policy_spec.rb @@ -193,16 +193,24 @@ RSpec.describe GroupPolicy do let(:current_user) { admin } specify do - expect_allowed(*read_group_permissions) - expect_allowed(*guest_permissions) - expect_allowed(*reporter_permissions) - expect_allowed(*developer_permissions) - expect_allowed(*maintainer_permissions) - expect_allowed(*owner_permissions) + expect_disallowed(*read_group_permissions) + expect_disallowed(*guest_permissions) + expect_disallowed(*reporter_permissions) + expect_disallowed(*developer_permissions) + expect_disallowed(*maintainer_permissions) + expect_disallowed(*owner_permissions) end context 'with admin mode', :enable_admin_mode do - specify { expect_allowed(*admin_permissions) } + specify do + expect_allowed(*read_group_permissions) + expect_allowed(*guest_permissions) + expect_allowed(*reporter_permissions) + expect_allowed(*developer_permissions) + expect_allowed(*maintainer_permissions) + expect_allowed(*owner_permissions) + expect_allowed(*admin_permissions) + end end it_behaves_like 'deploy token does not get confused with user' do @@ -773,7 +781,13 @@ RSpec.describe GroupPolicy do context 'admin' do let(:current_user) { admin } - it { is_expected.to be_allowed(:create_jira_connect_subscription) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed(:create_jira_connect_subscription) } + end + + context 'when admin mode is disabled' do + it { is_expected.to be_disallowed(:create_jira_connect_subscription) } + end end context 'with owner' do @@ -817,7 +831,13 @@ RSpec.describe GroupPolicy do context 'admin' do let(:current_user) { admin } - it { is_expected.to be_allowed(:read_package) } + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed(:read_package) } + end + + context 'when admin mode is disabled' do + it { is_expected.to be_disallowed(:read_package) } + end end context 'with owner' do diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index 6ba3ab6aace..60c54f97312 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -64,8 +64,8 @@ RSpec.describe ProjectPolicy do end it 'disables boards and lists permissions' do - expect_disallowed :read_board, :create_board, :update_board - expect_disallowed :read_list, :create_list, :update_list, :admin_list + expect_disallowed :read_issue_board, :create_board, :update_board + expect_disallowed :read_issue_board_list, :create_list, :update_list, :admin_issue_board_list end context 'when external tracker configured' do @@ -105,6 +105,10 @@ RSpec.describe ProjectPolicy do context 'pipeline feature' do let(:project) { private_project } + before do + private_project.add_developer(current_user) + end + describe 'for unconfirmed user' do let(:current_user) { create(:user, confirmed_at: nil) } @@ -1263,4 +1267,90 @@ RSpec.describe ProjectPolicy do end end end + + describe 'access_security_and_compliance' do + context 'when the "Security & Compliance" is enabled' do + before do + project.project_feature.update!(security_and_compliance_access_level: Featurable::PRIVATE) + end + + %w[owner maintainer developer].each do |role| + context "when the role is #{role}" do + let(:current_user) { public_send(role) } + + it { is_expected.to be_allowed(:access_security_and_compliance) } + end + end + + context 'with admin' do + let(:current_user) { admin } + + context 'when admin mode enabled', :enable_admin_mode do + it { is_expected.to be_allowed(:access_security_and_compliance) } + end + + context 'when admin mode disabled' do + it { is_expected.to be_disallowed(:access_security_and_compliance) } + end + end + + %w[reporter guest].each do |role| + context "when the role is #{role}" do + let(:current_user) { public_send(role) } + + it { is_expected.to be_disallowed(:access_security_and_compliance) } + end + end + + context 'with non member' do + let(:current_user) { non_member } + + it { is_expected.to be_disallowed(:access_security_and_compliance) } + end + + context 'with anonymous' do + let(:current_user) { anonymous } + + it { is_expected.to be_disallowed(:access_security_and_compliance) } + end + end + + context 'when the "Security & Compliance" is not enabled' do + before do + project.project_feature.update!(security_and_compliance_access_level: Featurable::DISABLED) + end + + %w[owner maintainer developer reporter guest].each do |role| + context "when the role is #{role}" do + let(:current_user) { public_send(role) } + + it { is_expected.to be_disallowed(:access_security_and_compliance) } + end + end + + context 'with admin' do + let(:current_user) { admin } + + context 'when admin mode enabled', :enable_admin_mode do + it { is_expected.to be_disallowed(:access_security_and_compliance) } + end + + context 'when admin mode disabled' do + it { is_expected.to be_disallowed(:access_security_and_compliance) } + end + end + + context 'with non member' do + let(:current_user) { non_member } + + it { is_expected.to be_disallowed(:access_security_and_compliance) } + end + + context 'with anonymous' do + let(:current_user) { anonymous } + + it { is_expected.to be_disallowed(:access_security_and_compliance) } + end + end + end end diff --git a/spec/presenters/ci/build_runner_presenter_spec.rb b/spec/presenters/ci/build_runner_presenter_spec.rb index 43b677483ce..1eecc9d1ce6 100644 --- a/spec/presenters/ci/build_runner_presenter_spec.rb +++ b/spec/presenters/ci/build_runner_presenter_spec.rb @@ -271,4 +271,28 @@ RSpec.describe Ci::BuildRunnerPresenter do end end end + + describe '#variables' do + subject { presenter.variables } + + let(:build) { create(:ci_build) } + + it 'returns a Collection' do + is_expected.to be_an_instance_of(Gitlab::Ci::Variables::Collection) + end + end + + describe '#runner_variables' do + subject { presenter.runner_variables } + + let(:build) { create(:ci_build) } + + it 'returns an array' do + is_expected.to be_an_instance_of(Array) + end + + it 'returns the expected variables' do + is_expected.to eq(presenter.variables.to_runner_variables) + end + end end diff --git a/spec/presenters/packages/composer/packages_presenter_spec.rb b/spec/presenters/packages/composer/packages_presenter_spec.rb index 19d99a62468..c4217b6e37c 100644 --- a/spec/presenters/packages/composer/packages_presenter_spec.rb +++ b/spec/presenters/packages/composer/packages_presenter_spec.rb @@ -67,10 +67,15 @@ RSpec.describe ::Packages::Composer::PackagesPresenter do { 'packages' => [], 'provider-includes' => { 'p/%hash%.json' => { 'sha256' => /^\h+$/ } }, - 'providers-url' => "/api/v4/group/#{group.id}/-/packages/composer/%package%$%hash%.json" + 'providers-url' => "prefix/api/v4/group/#{group.id}/-/packages/composer/%package%$%hash%.json", + 'metadata-url' => "prefix/api/v4/group/#{group.id}/-/packages/composer/p2/%package%.json" } end + before do + stub_config(gitlab: { relative_url_root: 'prefix' }) + end + it 'returns the provider json' do expect(subject).to match(expected_json) end diff --git a/spec/presenters/packages/detail/package_presenter_spec.rb b/spec/presenters/packages/detail/package_presenter_spec.rb index e38bbbe600c..5e20eed877f 100644 --- a/spec/presenters/packages/detail/package_presenter_spec.rb +++ b/spec/presenters/packages/detail/package_presenter_spec.rb @@ -16,7 +16,10 @@ RSpec.describe ::Packages::Detail::PackagePresenter do created_at: file.created_at, download_path: file.download_path, file_name: file.file_name, - size: file.size + size: file.size, + file_md5: file.file_md5, + file_sha1: file.file_sha1, + file_sha256: file.file_sha256 } end end diff --git a/spec/presenters/project_presenter_spec.rb b/spec/presenters/project_presenter_spec.rb index 98bcbd8384b..a9a5ecb3299 100644 --- a/spec/presenters/project_presenter_spec.rb +++ b/spec/presenters/project_presenter_spec.rb @@ -183,6 +183,14 @@ RSpec.describe ProjectPresenter do context 'not empty repo' do let(:project) { create(:project, :repository) } + context 'if no current user' do + let(:user) { nil } + + it 'returns false' do + expect(presenter.can_current_user_push_code?).to be(false) + end + end + it 'returns true if user can push to default branch' do project.add_developer(user) @@ -350,7 +358,7 @@ RSpec.describe ProjectPresenter do is_link: false, label: a_string_including("New file"), link: presenter.project_new_blob_path(project, 'master'), - class_modifier: 'dashed' + class_modifier: 'btn-dashed' ) end @@ -555,6 +563,51 @@ RSpec.describe ProjectPresenter do end end end + + describe '#upload_anchor_data' do + context 'with empty_repo_upload enabled' do + before do + stub_experiments(empty_repo_upload: :candidate) + end + + context 'user can push to branch' do + before do + project.add_developer(user) + end + + it 'returns upload_anchor_data' do + expect(presenter.upload_anchor_data).to have_attributes( + is_link: false, + label: a_string_including('Upload file'), + data: { + "can_push_code" => "true", + "original_branch" => "master", + "path" => "/#{project.full_path}/-/create/master", + "project_path" => project.path, + "target_branch" => "master" + } + ) + end + end + + context 'user cannot push to branch' do + it 'returns nil' do + expect(presenter.upload_anchor_data).to be_nil + end + end + end + + context 'with empty_repo_upload disabled' do + before do + stub_experiments(empty_repo_upload: :control) + project.add_developer(user) + end + + it 'returns nil' do + expect(presenter.upload_anchor_data).to be_nil + end + end + end end describe '#statistics_buttons' do @@ -594,13 +647,47 @@ RSpec.describe ProjectPresenter do end end + describe 'experiment(:repo_integrations_link)' do + context 'when enabled' do + before do + stub_experiments(repo_integrations_link: :candidate) + end + + it 'includes a button to configure integrations for maintainers' do + project.add_maintainer(user) + + expect(empty_repo_statistics_buttons.map(&:label)).to include( + a_string_including('Configure Integration') + ) + end + + it 'does not include a button if not a maintainer' do + expect(empty_repo_statistics_buttons.map(&:label)).not_to include( + a_string_including('Configure Integration') + ) + end + end + + context 'when disabled' do + it 'does not include a button' do + project.add_maintainer(user) + + expect(empty_repo_statistics_buttons.map(&:label)).not_to include( + a_string_including('Configure Integration') + ) + end + end + end + context 'for a developer' do before do project.add_developer(user) + stub_experiments(empty_repo_upload: :candidate) end it 'orders the items correctly' do expect(empty_repo_statistics_buttons.map(&:label)).to start_with( + a_string_including('Upload'), a_string_including('New'), a_string_including('README'), a_string_including('LICENSE'), @@ -609,6 +696,16 @@ RSpec.describe ProjectPresenter do a_string_including('CI/CD') ) end + + context 'when not in the upload experiment' do + before do + stub_experiments(empty_repo_upload: :control) + end + + it 'does not include upload button' do + expect(empty_repo_statistics_buttons.map(&:label)).not_to start_with(a_string_including('Upload')) + end + end end end @@ -694,4 +791,20 @@ RSpec.describe ProjectPresenter do end end end + + describe 'empty_repo_upload_experiment?' do + subject { presenter.empty_repo_upload_experiment? } + + it 'returns false when upload_anchor_data is nil' do + allow(presenter).to receive(:upload_anchor_data).and_return(nil) + + expect(subject).to be false + end + + it 'returns true when upload_anchor_data exists' do + allow(presenter).to receive(:upload_anchor_data).and_return(true) + + expect(subject).to be true + end + end end diff --git a/spec/presenters/projects/import_export/project_export_presenter_spec.rb b/spec/presenters/projects/import_export/project_export_presenter_spec.rb index b2b2ce35f34..d5776ba2323 100644 --- a/spec/presenters/projects/import_export/project_export_presenter_spec.rb +++ b/spec/presenters/projects/import_export/project_export_presenter_spec.rb @@ -86,14 +86,22 @@ RSpec.describe Projects::ImportExport::ProjectExportPresenter do context 'as admin' do let(:user) { create(:admin) } - it 'exports group members as admin' do - expect(member_emails).to include('group@member.com') - end + context 'when admin mode is enabled', :enable_admin_mode do + it 'exports group members as admin' do + expect(member_emails).to include('group@member.com') + end + + it 'exports group members as project members' do + member_types = subject.project_members.map { |pm| pm.source_type } - it 'exports group members as project members' do - member_types = subject.project_members.map { |pm| pm.source_type } + expect(member_types).to all(eq('Project')) + end + end - expect(member_types).to all(eq('Project')) + context 'when admin mode is disabled' do + it 'does not export group members' do + expect(member_emails).not_to include('group@member.com') + end end end end diff --git a/spec/presenters/snippet_presenter_spec.rb b/spec/presenters/snippet_presenter_spec.rb index a1d987ed78f..b0387206bd9 100644 --- a/spec/presenters/snippet_presenter_spec.rb +++ b/spec/presenters/snippet_presenter_spec.rb @@ -159,7 +159,7 @@ RSpec.describe SnippetPresenter do let(:snippet) { create(:snippet, :repository, author: user) } it 'returns repository first blob' do - expect(subject).to eq snippet.blobs.first + expect(subject.name).to eq snippet.blobs.first.name end end end diff --git a/spec/requests/api/admin/plan_limits_spec.rb b/spec/requests/api/admin/plan_limits_spec.rb new file mode 100644 index 00000000000..6bc133f67c0 --- /dev/null +++ b/spec/requests/api/admin/plan_limits_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Admin::PlanLimits, 'PlanLimits' do + let_it_be(:user) { create(:user) } + let_it_be(:admin) { create(:admin) } + let_it_be(:plan) { create(:plan, name: 'default') } + + describe 'GET /application/plan_limits' do + context 'as a non-admin user' do + it 'returns 403' do + get api('/application/plan_limits', user) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'as an admin user' do + context 'no params' do + it 'returns plan limits' do + get api('/application/plan_limits', admin) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Hash + expect(json_response['conan_max_file_size']).to eq(Plan.default.actual_limits.conan_max_file_size) + expect(json_response['generic_packages_max_file_size']).to eq(Plan.default.actual_limits.generic_packages_max_file_size) + expect(json_response['maven_max_file_size']).to eq(Plan.default.actual_limits.maven_max_file_size) + expect(json_response['npm_max_file_size']).to eq(Plan.default.actual_limits.npm_max_file_size) + expect(json_response['nuget_max_file_size']).to eq(Plan.default.actual_limits.nuget_max_file_size) + expect(json_response['pypi_max_file_size']).to eq(Plan.default.actual_limits.pypi_max_file_size) + end + end + + context 'correct plan name in params' do + before do + @params = { plan_name: 'default' } + end + + it 'returns plan limits' do + get api('/application/plan_limits', admin), params: @params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Hash + expect(json_response['conan_max_file_size']).to eq(Plan.default.actual_limits.conan_max_file_size) + expect(json_response['generic_packages_max_file_size']).to eq(Plan.default.actual_limits.generic_packages_max_file_size) + expect(json_response['maven_max_file_size']).to eq(Plan.default.actual_limits.maven_max_file_size) + expect(json_response['npm_max_file_size']).to eq(Plan.default.actual_limits.npm_max_file_size) + expect(json_response['nuget_max_file_size']).to eq(Plan.default.actual_limits.nuget_max_file_size) + expect(json_response['pypi_max_file_size']).to eq(Plan.default.actual_limits.pypi_max_file_size) + end + end + + context 'invalid plan name in params' do + before do + @params = { plan_name: 'my-plan' } + end + + it 'returns validation error' do + get api('/application/plan_limits', admin), params: @params + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to eq('plan_name does not have a valid value') + end + end + end + end + + describe 'PUT /application/plan_limits' do + context 'as a non-admin user' do + it 'returns 403' do + put api('/application/plan_limits', user), params: { plan_name: 'default' } + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'as an admin user' do + context 'correct params' do + it 'updates multiple plan limits' do + put api('/application/plan_limits', admin), params: { + 'plan_name': 'default', + 'conan_max_file_size': 10, + 'generic_packages_max_file_size': 20, + 'maven_max_file_size': 30, + 'npm_max_file_size': 40, + 'nuget_max_file_size': 50, + 'pypi_max_file_size': 60 + } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Hash + expect(json_response['conan_max_file_size']).to eq(10) + expect(json_response['generic_packages_max_file_size']).to eq(20) + expect(json_response['maven_max_file_size']).to eq(30) + expect(json_response['npm_max_file_size']).to eq(40) + expect(json_response['nuget_max_file_size']).to eq(50) + expect(json_response['pypi_max_file_size']).to eq(60) + end + + it 'updates single plan limits' do + put api('/application/plan_limits', admin), params: { + 'plan_name': 'default', + 'maven_max_file_size': 100 + } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Hash + expect(json_response['maven_max_file_size']).to eq(100) + end + end + + context 'empty params' do + it 'fails to update plan limits' do + put api('/application/plan_limits', admin), params: {} + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to match('plan_name is missing') + end + end + + context 'params with wrong type' do + it 'fails to update plan limits' do + put api('/application/plan_limits', admin), params: { + 'plan_name': 'default', + 'conan_max_file_size': 'a', + 'generic_packages_max_file_size': 'b', + 'maven_max_file_size': 'c', + 'npm_max_file_size': 'd', + 'nuget_max_file_size': 'e', + 'pypi_max_file_size': 'f' + } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to include( + 'conan_max_file_size is invalid', + 'generic_packages_max_file_size is invalid', + 'maven_max_file_size is invalid', + 'generic_packages_max_file_size is invalid', + 'npm_max_file_size is invalid', + 'nuget_max_file_size is invalid', + 'pypi_max_file_size is invalid' + ) + end + end + + context 'missing plan_name in params' do + it 'fails to update plan limits' do + put api('/application/plan_limits', admin), params: { 'conan_max_file_size': 0 } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to match('plan_name is missing') + end + end + + context 'additional undeclared params' do + before do + Plan.default.actual_limits.update!({ 'golang_max_file_size': 1000 }) + end + + it 'updates only declared plan limits' do + put api('/application/plan_limits', admin), params: { + 'plan_name': 'default', + 'pypi_max_file_size': 200, + 'golang_max_file_size': 999 + } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Hash + expect(json_response['pypi_max_file_size']).to eq(200) + expect(json_response['golang_max_file_size']).to be_nil + expect(Plan.default.actual_limits.golang_max_file_size).to eq(1000) + end + end + end + end +end diff --git a/spec/requests/api/api_spec.rb b/spec/requests/api/api_spec.rb index 8bd6049e6fa..522030652bd 100644 --- a/spec/requests/api/api_spec.rb +++ b/spec/requests/api/api_spec.rb @@ -112,6 +112,7 @@ RSpec.describe API::API do 'meta.project' => project.full_path, 'meta.root_namespace' => project.namespace.full_path, 'meta.user' => user.username, + 'meta.client_id' => an_instance_of(String), 'meta.feature_category' => 'issue_tracking') end end @@ -125,6 +126,7 @@ RSpec.describe API::API do expect(log_context).to match('correlation_id' => an_instance_of(String), 'meta.caller_id' => '/api/:version/users', 'meta.remote_ip' => an_instance_of(String), + 'meta.client_id' => an_instance_of(String), 'meta.feature_category' => 'users') end end @@ -133,6 +135,28 @@ RSpec.describe API::API do end end + describe 'Marginalia comments' do + context 'GET /user/:id' do + let_it_be(:user) { create(:user) } + let(:component_map) do + { + "application" => "test", + "endpoint_id" => "/api/:version/users/:id" + } + end + + subject { ActiveRecord::QueryRecorder.new { get api("/users/#{user.id}", user) } } + + it 'generates a query that includes the expected annotations' do + expect(subject.log.last).to match(/correlation_id:.*/) + + component_map.each do |component, value| + expect(subject.log.last).to include("#{component}:#{value}") + end + end + end + end + describe 'supported content-types' do context 'GET /user/:id.txt' do let_it_be(:user) { create(:user) } diff --git a/spec/requests/api/ci/pipelines_spec.rb b/spec/requests/api/ci/pipelines_spec.rb index a9afbd8bd72..d0c2b383013 100644 --- a/spec/requests/api/ci/pipelines_spec.rb +++ b/spec/requests/api/ci/pipelines_spec.rb @@ -34,7 +34,7 @@ RSpec.describe API::Ci::Pipelines do expect(json_response.first['sha']).to match(/\A\h{40}\z/) expect(json_response.first['id']).to eq pipeline.id expect(json_response.first['web_url']).to be_present - expect(json_response.first.keys).to contain_exactly(*%w[id sha ref status web_url created_at updated_at]) + expect(json_response.first.keys).to contain_exactly(*%w[id project_id sha ref status web_url created_at updated_at]) end context 'when parameter is passed' do @@ -350,6 +350,7 @@ RSpec.describe API::Ci::Pipelines do expect(json_job['pipeline']).not_to be_empty expect(json_job['pipeline']['id']).to eq job.pipeline.id + expect(json_job['pipeline']['project_id']).to eq job.pipeline.project_id expect(json_job['pipeline']['ref']).to eq job.pipeline.ref expect(json_job['pipeline']['sha']).to eq job.pipeline.sha expect(json_job['pipeline']['status']).to eq job.pipeline.status @@ -512,6 +513,7 @@ RSpec.describe API::Ci::Pipelines do expect(json_bridge['pipeline']).not_to be_empty expect(json_bridge['pipeline']['id']).to eq bridge.pipeline.id + expect(json_bridge['pipeline']['project_id']).to eq bridge.pipeline.project_id expect(json_bridge['pipeline']['ref']).to eq bridge.pipeline.ref expect(json_bridge['pipeline']['sha']).to eq bridge.pipeline.sha expect(json_bridge['pipeline']['status']).to eq bridge.pipeline.status @@ -522,6 +524,7 @@ RSpec.describe API::Ci::Pipelines do expect(json_bridge['downstream_pipeline']).not_to be_empty expect(json_bridge['downstream_pipeline']['id']).to eq downstream_pipeline.id + expect(json_bridge['downstream_pipeline']['project_id']).to eq downstream_pipeline.project_id expect(json_bridge['downstream_pipeline']['ref']).to eq downstream_pipeline.ref expect(json_bridge['downstream_pipeline']['sha']).to eq downstream_pipeline.sha expect(json_bridge['downstream_pipeline']['status']).to eq downstream_pipeline.status diff --git a/spec/requests/api/ci/runner/jobs_artifacts_spec.rb b/spec/requests/api/ci/runner/jobs_artifacts_spec.rb index 4d8da50f8f0..9369b6aa464 100644 --- a/spec/requests/api/ci/runner/jobs_artifacts_spec.rb +++ b/spec/requests/api/ci/runner/jobs_artifacts_spec.rb @@ -17,9 +17,9 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do end describe '/api/v4/jobs' do - let(:root_namespace) { create(:namespace) } - let(:namespace) { create(:namespace, parent: root_namespace) } - let(:project) { create(:project, namespace: namespace, shared_runners_enabled: false) } + let(:parent_group) { create(:group) } + let(:group) { create(:group, parent: parent_group) } + let(:project) { create(:project, namespace: group, shared_runners_enabled: false) } let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') } let(:runner) { create(:ci_runner, :project, projects: [project]) } let(:user) { create(:user) } @@ -78,7 +78,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do before do stub_application_setting(max_artifacts_size: application_max_size) - root_namespace.update!(max_artifacts_size: sample_max_size) + parent_group.update!(max_artifacts_size: sample_max_size) end it_behaves_like 'failed request' @@ -90,8 +90,8 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do before do stub_application_setting(max_artifacts_size: application_max_size) - root_namespace.update!(max_artifacts_size: root_namespace_max_size) - namespace.update!(max_artifacts_size: sample_max_size) + parent_group.update!(max_artifacts_size: root_namespace_max_size) + group.update!(max_artifacts_size: sample_max_size) end it_behaves_like 'failed request' @@ -104,8 +104,8 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do before do stub_application_setting(max_artifacts_size: application_max_size) - root_namespace.update!(max_artifacts_size: root_namespace_max_size) - namespace.update!(max_artifacts_size: child_namespace_max_size) + parent_group.update!(max_artifacts_size: root_namespace_max_size) + group.update!(max_artifacts_size: child_namespace_max_size) project.update!(max_artifacts_size: sample_max_size) end diff --git a/spec/requests/api/ci/runner/jobs_put_spec.rb b/spec/requests/api/ci/runner/jobs_put_spec.rb index f4c99307b1a..b5d2c4608c5 100644 --- a/spec/requests/api/ci/runner/jobs_put_spec.rb +++ b/spec/requests/api/ci/runner/jobs_put_spec.rb @@ -17,9 +17,8 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do end describe '/api/v4/jobs' do - let(:root_namespace) { create(:namespace) } - let(:namespace) { create(:namespace, parent: root_namespace) } - let(:project) { create(:project, namespace: namespace, shared_runners_enabled: false) } + let(:group) { create(:group, :nested) } + let(:project) { create(:project, namespace: group, shared_runners_enabled: false) } let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') } let(:runner) { create(:ci_runner, :project, projects: [project]) } let(:user) { create(:user) } diff --git a/spec/requests/api/ci/runner/jobs_request_post_spec.rb b/spec/requests/api/ci/runner/jobs_request_post_spec.rb index 74d8e3f7ae8..aced094e219 100644 --- a/spec/requests/api/ci/runner/jobs_request_post_spec.rb +++ b/spec/requests/api/ci/runner/jobs_request_post_spec.rb @@ -17,9 +17,8 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do end describe '/api/v4/jobs' do - let(:root_namespace) { create(:namespace) } - let(:namespace) { create(:namespace, parent: root_namespace) } - let(:project) { create(:project, namespace: namespace, shared_runners_enabled: false) } + let(:group) { create(:group, :nested) } + let(:project) { create(:project, namespace: group, shared_runners_enabled: false) } let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') } let(:runner) { create(:ci_runner, :project, projects: [project]) } let(:user) { create(:user) } @@ -198,7 +197,12 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do 'when' => 'on_success' }] end - let(:expected_features) { { 'trace_sections' => true } } + let(:expected_features) do + { + 'trace_sections' => true, + 'failure_reasons' => include('script_failure') + } + end it 'picks a job' do request_job info: { platform: :darwin } @@ -220,7 +224,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do expect(json_response['artifacts']).to eq(expected_artifacts) expect(json_response['cache']).to eq(expected_cache) expect(json_response['variables']).to include(*expected_variables) - expect(json_response['features']).to eq(expected_features) + expect(json_response['features']).to match(expected_features) end it 'creates persistent ref' do @@ -793,6 +797,50 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do end end + describe 'setting the application context' do + subject { request_job } + + context 'when triggered by a user' do + let(:job) { create(:ci_build, user: user, project: project) } + + subject { request_job(id: job.id) } + + it_behaves_like 'storing arguments in the application context' do + let(:expected_params) { { user: user.username, project: project.full_path, client_id: "user/#{user.id}" } } + end + + it_behaves_like 'not executing any extra queries for the application context', 3 do + # Extra queries: User, Project, Route + let(:subject_proc) { proc { request_job(id: job.id) } } + end + end + + context 'when the runner is of project type' do + it_behaves_like 'storing arguments in the application context' do + let(:expected_params) { { project: project.full_path, client_id: "runner/#{runner.id}" } } + end + + it_behaves_like 'not executing any extra queries for the application context', 2 do + # Extra queries: Project, Route + let(:subject_proc) { proc { request_job } } + end + end + + context 'when the runner is of group type' do + let(:group) { create(:group) } + let(:runner) { create(:ci_runner, :group, groups: [group]) } + + it_behaves_like 'storing arguments in the application context' do + let(:expected_params) { { root_namespace: group.full_path_components.first, client_id: "runner/#{runner.id}" } } + end + + it_behaves_like 'not executing any extra queries for the application context', 2 do + # Extra queries: Group, Route + let(:subject_proc) { proc { request_job } } + end + end + end + def request_job(token = runner.token, **params) new_params = params.merge(token: token, last_update: last_update) post api('/jobs/request'), params: new_params.to_json, headers: { 'User-Agent' => user_agent, 'Content-Type': 'application/json' } diff --git a/spec/requests/api/ci/runner/jobs_trace_spec.rb b/spec/requests/api/ci/runner/jobs_trace_spec.rb index 5b7a33d23d8..659cf055023 100644 --- a/spec/requests/api/ci/runner/jobs_trace_spec.rb +++ b/spec/requests/api/ci/runner/jobs_trace_spec.rb @@ -17,9 +17,8 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do end describe '/api/v4/jobs' do - let(:root_namespace) { create(:namespace) } - let(:namespace) { create(:namespace, parent: root_namespace) } - let(:project) { create(:project, namespace: namespace, shared_runners_enabled: false) } + let(:group) { create(:group, :nested) } + let(:project) { create(:project, namespace: group, shared_runners_enabled: false) } let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') } let(:runner) { create(:ci_runner, :project, projects: [project]) } let(:user) { create(:user) } diff --git a/spec/requests/api/ci/runner/runners_delete_spec.rb b/spec/requests/api/ci/runner/runners_delete_spec.rb index 75960a1a1c0..6c6c465f161 100644 --- a/spec/requests/api/ci/runner/runners_delete_spec.rb +++ b/spec/requests/api/ci/runner/runners_delete_spec.rb @@ -37,8 +37,10 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do context 'when valid token is provided' do let(:runner) { create(:ci_runner) } + subject { delete api('/runners'), params: { token: runner.token } } + it 'deletes Runner' do - delete api('/runners'), params: { token: runner.token } + subject expect(response).to have_gitlab_http_status(:no_content) expect(::Ci::Runner.count).to eq(0) @@ -48,6 +50,10 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do let(:request) { api('/runners') } let(:params) { { token: runner.token } } end + + it_behaves_like 'storing arguments in the application context' do + let(:expected_params) { { client_id: "runner/#{runner.id}" } } + end end end end diff --git a/spec/requests/api/ci/runner/runners_post_spec.rb b/spec/requests/api/ci/runner/runners_post_spec.rb index 7c362fae7d2..7984b1d4ca8 100644 --- a/spec/requests/api/ci/runner/runners_post_spec.rb +++ b/spec/requests/api/ci/runner/runners_post_spec.rb @@ -35,25 +35,44 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do end context 'when valid token is provided' do - it 'creates runner with default values' do - post api('/runners'), params: { token: registration_token } + def request + post api('/runners'), params: { token: token } + end - runner = ::Ci::Runner.first + context 'with a registration token' do + let(:token) { registration_token } - expect(response).to have_gitlab_http_status(:created) - expect(json_response['id']).to eq(runner.id) - expect(json_response['token']).to eq(runner.token) - expect(runner.run_untagged).to be true - expect(runner.active).to be true - expect(runner.token).not_to eq(registration_token) - expect(runner).to be_instance_type + it 'creates runner with default values' do + request + + runner = ::Ci::Runner.first + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['id']).to eq(runner.id) + expect(json_response['token']).to eq(runner.token) + expect(runner.run_untagged).to be true + expect(runner.active).to be true + expect(runner.token).not_to eq(registration_token) + expect(runner).to be_instance_type + end + + it_behaves_like 'storing arguments in the application context' do + subject { request } + + let(:expected_params) { { client_id: "runner/#{::Ci::Runner.first.id}" } } + end + + it_behaves_like 'not executing any extra queries for the application context' do + let(:subject_proc) { proc { request } } + end end context 'when project token is used' do let(:project) { create(:project) } + let(:token) { project.runners_token } it 'creates project runner' do - post api('/runners'), params: { token: project.runners_token } + request expect(response).to have_gitlab_http_status(:created) expect(project.runners.size).to eq(1) @@ -62,13 +81,24 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do expect(runner.token).not_to eq(project.runners_token) expect(runner).to be_project_type end + + it_behaves_like 'storing arguments in the application context' do + subject { request } + + let(:expected_params) { { project: project.full_path, client_id: "runner/#{::Ci::Runner.first.id}" } } + end + + it_behaves_like 'not executing any extra queries for the application context' do + let(:subject_proc) { proc { request } } + end end context 'when group token is used' do let(:group) { create(:group) } + let(:token) { group.runners_token } it 'creates a group runner' do - post api('/runners'), params: { token: group.runners_token } + request expect(response).to have_gitlab_http_status(:created) expect(group.runners.reload.size).to eq(1) @@ -77,6 +107,16 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do expect(runner.token).not_to eq(group.runners_token) expect(runner).to be_group_type end + + it_behaves_like 'storing arguments in the application context' do + subject { request } + + let(:expected_params) { { root_namespace: group.full_path_components.first, client_id: "runner/#{::Ci::Runner.first.id}" } } + end + + it_behaves_like 'not executing any extra queries for the application context' do + let(:subject_proc) { proc { request } } + end end end diff --git a/spec/requests/api/ci/runner/runners_verify_post_spec.rb b/spec/requests/api/ci/runner/runners_verify_post_spec.rb index e2f5f9b2d68..c2e97446738 100644 --- a/spec/requests/api/ci/runner/runners_verify_post_spec.rb +++ b/spec/requests/api/ci/runner/runners_verify_post_spec.rb @@ -37,11 +37,17 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do end context 'when valid token is provided' do + subject { post api('/runners/verify'), params: { token: runner.token } } + it 'verifies Runner credentials' do - post api('/runners/verify'), params: { token: runner.token } + subject expect(response).to have_gitlab_http_status(:ok) end + + it_behaves_like 'storing arguments in the application context' do + let(:expected_params) { { client_id: "runner/#{runner.id}" } } + end end end end diff --git a/spec/requests/api/commit_statuses_spec.rb b/spec/requests/api/commit_statuses_spec.rb index bec15b788c3..10fa15d468f 100644 --- a/spec/requests/api/commit_statuses_spec.rb +++ b/spec/requests/api/commit_statuses_spec.rb @@ -291,7 +291,7 @@ RSpec.describe API::CommitStatuses do end context 'when retrying a commit status' do - before do + subject(:post_request) do post api(post_url, developer), params: { state: 'failed', name: 'test', ref: 'master' } @@ -300,15 +300,45 @@ RSpec.describe API::CommitStatuses do end it 'correctly posts a new commit status' do + post_request + expect(response).to have_gitlab_http_status(:created) expect(json_response['sha']).to eq(commit.id) expect(json_response['status']).to eq('success') end - it 'retries a commit status', :sidekiq_might_not_need_inline do - expect(CommitStatus.count).to eq 2 - expect(CommitStatus.first).to be_retried - expect(CommitStatus.last.pipeline).to be_success + context 'feature flags' do + using RSpec::Parameterized::TableSyntax + + where(:ci_fix_commit_status_retried, :ci_remove_update_retried_from_process_pipeline, :previous_statuses_retried) do + true | true | true + true | false | true + false | true | false + false | false | true + end + + with_them do + before do + stub_feature_flags( + ci_fix_commit_status_retried: ci_fix_commit_status_retried, + ci_remove_update_retried_from_process_pipeline: ci_remove_update_retried_from_process_pipeline + ) + end + + it 'retries a commit status', :sidekiq_might_not_need_inline do + post_request + + expect(CommitStatus.count).to eq 2 + + if previous_statuses_retried + expect(CommitStatus.first).to be_retried + expect(CommitStatus.last.pipeline).to be_success + else + expect(CommitStatus.first).not_to be_retried + expect(CommitStatus.last.pipeline).to be_failed + end + end + end end end diff --git a/spec/requests/api/composer_packages_spec.rb b/spec/requests/api/composer_packages_spec.rb index 06d4a2c6017..30a831d24fd 100644 --- a/spec/requests/api/composer_packages_spec.rb +++ b/spec/requests/api/composer_packages_spec.rb @@ -222,6 +222,52 @@ RSpec.describe API::ComposerPackages do it_behaves_like 'rejects Composer access with unknown group id' end + describe 'GET /api/v4/group/:id/-/packages/composer/p2/*package_name.json' do + let(:package_name) { 'foobar' } + let(:url) { "/group/#{group.id}/-/packages/composer/p2/#{package_name}.json" } + + subject { get api(url), headers: headers } + + context 'with no packages' do + include_context 'Composer user type', :developer, true do + it_behaves_like 'returning response status', :not_found + end + end + + context 'with valid project' do + let!(:package) { create(:composer_package, :with_metadatum, name: package_name, project: project) } + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'Composer package api request' | :success + 'PUBLIC' | :developer | true | false | 'process Composer api request' | :unauthorized + 'PUBLIC' | :developer | false | true | 'Composer package api request' | :success + 'PUBLIC' | :developer | false | false | 'process Composer api request' | :unauthorized + 'PUBLIC' | :guest | true | true | 'Composer package api request' | :success + 'PUBLIC' | :guest | true | false | 'process Composer api request' | :unauthorized + 'PUBLIC' | :guest | false | true | 'Composer package api request' | :success + 'PUBLIC' | :guest | false | false | 'process Composer api request' | :unauthorized + 'PUBLIC' | :anonymous | false | true | 'Composer package api request' | :success + 'PRIVATE' | :developer | true | true | 'Composer package api request' | :success + 'PRIVATE' | :developer | true | false | 'process Composer api request' | :unauthorized + 'PRIVATE' | :developer | false | true | 'process Composer api request' | :not_found + 'PRIVATE' | :developer | false | false | 'process Composer api request' | :unauthorized + 'PRIVATE' | :guest | true | true | 'process Composer api request' | :not_found + 'PRIVATE' | :guest | true | false | 'process Composer api request' | :unauthorized + 'PRIVATE' | :guest | false | true | 'process Composer api request' | :not_found + 'PRIVATE' | :guest | false | false | 'process Composer api request' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'process Composer api request' | :not_found + end + + with_them do + include_context 'Composer api group access', params[:project_visibility_level], params[:user_role], params[:user_token] do + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + end + + it_behaves_like 'rejects Composer access with unknown group id' + end + describe 'POST /api/v4/projects/:id/packages/composer' do let(:url) { "/projects/#{project.id}/packages/composer" } let(:params) { {} } diff --git a/spec/requests/api/discussions_spec.rb b/spec/requests/api/discussions_spec.rb index bdfc1589c9e..258bd26c05a 100644 --- a/spec/requests/api/discussions_spec.rb +++ b/spec/requests/api/discussions_spec.rb @@ -68,18 +68,11 @@ RSpec.describe API::Discussions do mr_commit = '0b4bc9a49b562e85de7cc9e834518ea6828729b9' parent_commit = 'ae73cb07c9eeaf35924a10f713b364d32b2dd34f' file = "files/ruby/feature.rb" - line_range = { - "start_line_code" => Gitlab::Git.diff_line_code(file, 1, 1), - "end_line_code" => Gitlab::Git.diff_line_code(file, 1, 1), - "start_line_type" => "text", - "end_line_type" => "text" - } position = build( :text_diff_position, :added, file: file, new_line: 1, - line_range: line_range, base_sha: parent_commit, head_sha: mr_commit, start_sha: parent_commit diff --git a/spec/requests/api/environments_spec.rb b/spec/requests/api/environments_spec.rb index b1ac8f9eeec..303e510883d 100644 --- a/spec/requests/api/environments_spec.rb +++ b/spec/requests/api/environments_spec.rb @@ -265,4 +265,76 @@ RSpec.describe API::Environments do end end end + + describe "DELETE /projects/:id/environments/review_apps" do + shared_examples "delete stopped review environments" do + around do |example| + freeze_time { example.run } + end + + it "deletes the old stopped review apps" do + old_stopped_review_env = create(:environment, :with_review_app, :stopped, created_at: 31.days.ago, project: project) + new_stopped_review_env = create(:environment, :with_review_app, :stopped, project: project) + old_active_review_env = create(:environment, :with_review_app, :available, created_at: 31.days.ago, project: project) + old_stopped_other_env = create(:environment, :stopped, created_at: 31.days.ago, project: project) + new_stopped_other_env = create(:environment, :stopped, project: project) + old_active_other_env = create(:environment, :available, created_at: 31.days.ago, project: project) + + delete api("/projects/#{project.id}/environments/review_apps", current_user), params: { dry_run: false } + project.environments.reload + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response["scheduled_entries"].size).to eq(1) + expect(json_response["scheduled_entries"].first["id"]).to eq(old_stopped_review_env.id) + expect(json_response["unprocessable_entries"].size).to eq(0) + + expect(old_stopped_review_env.reload.auto_delete_at).to eq(1.week.from_now) + expect(new_stopped_review_env.reload.auto_delete_at).to be_nil + expect(old_active_review_env.reload.auto_delete_at).to be_nil + expect(old_stopped_other_env.reload.auto_delete_at).to be_nil + expect(new_stopped_other_env.reload.auto_delete_at).to be_nil + expect(old_active_other_env.reload.auto_delete_at).to be_nil + end + end + + context "as a maintainer" do + it_behaves_like "delete stopped review environments" do + let(:current_user) { user } + end + end + + context "as a developer" do + let(:developer) { create(:user) } + + before do + project.add_developer(developer) + end + + it_behaves_like "delete stopped review environments" do + let(:current_user) { developer } + end + end + + context "as a reporter" do + let(:reporter) { create(:user) } + + before do + project.add_reporter(reporter) + end + + it "rejects the request" do + delete api("/projects/#{project.id}/environments/review_apps", reporter) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context "as a non member" do + it "rejects the request" do + delete api("/projects/#{project.id}/environments/review_apps", non_member) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end end diff --git a/spec/requests/api/generic_packages_spec.rb b/spec/requests/api/generic_packages_spec.rb index a47be1ead9c..16d56b6cfbe 100644 --- a/spec/requests/api/generic_packages_spec.rb +++ b/spec/requests/api/generic_packages_spec.rb @@ -444,17 +444,17 @@ RSpec.describe API::GenericPackages do 'PUBLIC' | :guest | true | :user_basic_auth | :success 'PUBLIC' | :developer | true | :invalid_personal_access_token | :unauthorized 'PUBLIC' | :guest | true | :invalid_personal_access_token | :unauthorized - 'PUBLIC' | :developer | true | :invalid_user_basic_auth | :unauthorized - 'PUBLIC' | :guest | true | :invalid_user_basic_auth | :unauthorized + 'PUBLIC' | :developer | true | :invalid_user_basic_auth | :success + 'PUBLIC' | :guest | true | :invalid_user_basic_auth | :success 'PUBLIC' | :developer | false | :personal_access_token | :success 'PUBLIC' | :guest | false | :personal_access_token | :success 'PUBLIC' | :developer | false | :user_basic_auth | :success 'PUBLIC' | :guest | false | :user_basic_auth | :success 'PUBLIC' | :developer | false | :invalid_personal_access_token | :unauthorized 'PUBLIC' | :guest | false | :invalid_personal_access_token | :unauthorized - 'PUBLIC' | :developer | false | :invalid_user_basic_auth | :unauthorized - 'PUBLIC' | :guest | false | :invalid_user_basic_auth | :unauthorized - 'PUBLIC' | :anonymous | false | :none | :unauthorized + 'PUBLIC' | :developer | false | :invalid_user_basic_auth | :success + 'PUBLIC' | :guest | false | :invalid_user_basic_auth | :success + 'PUBLIC' | :anonymous | false | :none | :success 'PRIVATE' | :developer | true | :personal_access_token | :success 'PRIVATE' | :guest | true | :personal_access_token | :forbidden 'PRIVATE' | :developer | true | :user_basic_auth | :success diff --git a/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb b/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb index 44f924d8ae5..356e1e11def 100644 --- a/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb +++ b/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb @@ -13,7 +13,7 @@ RSpec.describe 'container repository details' do graphql_query_for( 'containerRepository', { id: container_repository_global_id }, - all_graphql_fields_for('ContainerRepositoryDetails') + all_graphql_fields_for('ContainerRepositoryDetails', excluded: ['pipeline']) ) end diff --git a/spec/requests/api/graphql/group/container_repositories_spec.rb b/spec/requests/api/graphql/group/container_repositories_spec.rb index 4aa775eba0f..939d7791d92 100644 --- a/spec/requests/api/graphql/group/container_repositories_spec.rb +++ b/spec/requests/api/graphql/group/container_repositories_spec.rb @@ -18,7 +18,7 @@ RSpec.describe 'getting container repositories in a group' do <<~GQL edges { node { - #{all_graphql_fields_for('container_repositories'.classify)} + #{all_graphql_fields_for('container_repositories'.classify, max_depth: 1)} } } GQL diff --git a/spec/requests/api/graphql/group/packages_spec.rb b/spec/requests/api/graphql/group/packages_spec.rb new file mode 100644 index 00000000000..85775598b2e --- /dev/null +++ b/spec/requests/api/graphql/group/packages_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'getting a package list for a group' do + include GraphqlHelpers + + let_it_be(:resource) { create(:group, :private) } + let_it_be(:group_two) { create(:group, :private) } + let_it_be(:project) { create(:project, :repository, group: resource) } + let_it_be(:another_project) { create(:project, :repository, group: resource) } + let_it_be(:group_two_project) { create(:project, :repository, group: group_two) } + let_it_be(:current_user) { create(:user) } + + let_it_be(:package) { create(:package, project: project) } + let_it_be(:npm_package) { create(:npm_package, project: group_two_project) } + let_it_be(:maven_package) { create(:maven_package, project: project) } + let_it_be(:debian_package) { create(:debian_package, project: another_project) } + let_it_be(:composer_package) { create(:composer_package, project: another_project) } + let_it_be(:composer_metadatum) do + create(:composer_metadatum, package: composer_package, + target_sha: 'afdeh', + composer_json: { name: 'x', type: 'y', license: 'z', version: 1 }) + end + + let(:package_names) { graphql_data_at(:group, :packages, :nodes, :name) } + let(:target_shas) { graphql_data_at(:group, :packages, :nodes, :metadata, :target_sha) } + let(:packages) { graphql_data_at(:group, :packages, :nodes) } + + let(:fields) do + <<~QUERY + nodes { + #{all_graphql_fields_for('packages'.classify, excluded: ['project'])} + metadata { #{query_graphql_fragment('ComposerMetadata')} } + } + QUERY + end + + let(:query) do + graphql_query_for( + 'group', + { 'fullPath' => resource.full_path }, + query_graphql_field('packages', {}, fields) + ) + end + + it_behaves_like 'group and project packages query' + + context 'with a batched query' do + let(:batch_query) do + <<~QUERY + { + a: group(fullPath: "#{resource.full_path}") { packages { nodes { name } } } + b: group(fullPath: "#{group_two.full_path}") { packages { nodes { name } } } + } + QUERY + end + + let(:a_packages_names) { graphql_data_at(:a, :packages, :nodes, :name) } + + before do + resource.add_reporter(current_user) + group_two.add_reporter(current_user) + post_graphql(batch_query, current_user: current_user) + end + + it 'returns an error for the second group and data for the first' do + expect(a_packages_names).to contain_exactly( + package.name, + maven_package.name, + debian_package.name, + composer_package.name + ) + expect_graphql_errors_to_include [/Packages can be requested only for one group at a time/] + expect(graphql_data_at(:b, :packages)).to be(nil) + end + end +end diff --git a/spec/requests/api/graphql/issue/issue_spec.rb b/spec/requests/api/graphql/issue/issue_spec.rb index 09e89f65882..e8b8caf6c2d 100644 --- a/spec/requests/api/graphql/issue/issue_spec.rb +++ b/spec/requests/api/graphql/issue/issue_spec.rb @@ -8,10 +8,9 @@ RSpec.describe 'Query.issue(id)' do let_it_be(:project) { create(:project) } let_it_be(:issue) { create(:issue, project: project) } let_it_be(:current_user) { create(:user) } + let_it_be(:issue_params) { { 'id' => issue.to_global_id.to_s } } let(:issue_data) { graphql_data['issue'] } - - let_it_be(:issue_params) { { 'id' => issue.to_global_id.to_s } } let(:issue_fields) { all_graphql_fields_for('Issue'.classify) } let(:query) do @@ -62,7 +61,7 @@ RSpec.describe 'Query.issue(id)' do ) end - context 'selecting any single field' do + context 'when selecting any single field' do where(:field) do scalar_fields_of('Issue').map { |name| [name] } end @@ -84,13 +83,13 @@ RSpec.describe 'Query.issue(id)' do end end - context 'selecting multiple fields' do + context 'when selecting multiple fields' do let(:issue_fields) { ['title', 'description', 'updatedBy { username }'] } it 'returns the Issue with the specified fields' do post_graphql(query, current_user: current_user) - expect(issue_data.keys).to eq( %w(title description updatedBy) ) + expect(issue_data.keys).to eq %w[title description updatedBy] expect(issue_data['title']).to eq(issue.title) expect(issue_data['description']).to eq(issue.description) expect(issue_data['updatedBy']['username']).to eq(issue.author.username) @@ -110,14 +109,14 @@ RSpec.describe 'Query.issue(id)' do it 'returns correct attributes' do post_graphql(query, current_user: current_user) - expect(issue_data.keys).to eq( %w(moved movedTo) ) + expect(issue_data.keys).to eq %w[moved movedTo] expect(issue_data['moved']).to eq(true) expect(issue_data['movedTo']['title']).to eq(new_issue.title) end end context 'when passed a non-Issue gid' do - let(:mr) {create(:merge_request)} + let(:mr) { create(:merge_request) } it 'returns an error' do gid = mr.to_global_id.to_s diff --git a/spec/requests/api/graphql/mutations/alert_management/alerts/create_alert_issue_spec.rb b/spec/requests/api/graphql/mutations/alert_management/alerts/create_alert_issue_spec.rb index 6141a172253..f637ca98353 100644 --- a/spec/requests/api/graphql/mutations/alert_management/alerts/create_alert_issue_spec.rb +++ b/spec/requests/api/graphql/mutations/alert_management/alerts/create_alert_issue_spec.rb @@ -20,7 +20,9 @@ RSpec.describe 'Create an alert issue from an alert' do errors alert { iid - issueIid + issue { + iid + } } issue { iid @@ -46,7 +48,7 @@ RSpec.describe 'Create an alert issue from an alert' do expect(mutation_response.slice('alert', 'issue')).to eq( 'alert' => { 'iid' => alert.iid.to_s, - 'issueIid' => new_issue.iid.to_s + 'issue' => { 'iid' => new_issue.iid.to_s } }, 'issue' => { 'iid' => new_issue.iid.to_s, diff --git a/spec/requests/api/graphql/mutations/merge_requests/accept_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/accept_spec.rb new file mode 100644 index 00000000000..2725b33d528 --- /dev/null +++ b/spec/requests/api/graphql/mutations/merge_requests/accept_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'accepting a merge request', :request_store do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:project) { create(:project, :public, :repository) } + let!(:merge_request) { create(:merge_request, source_project: project) } + let(:input) do + { + project_path: project.full_path, + iid: merge_request.iid.to_s, + sha: merge_request.diff_head_sha + } + end + + let(:mutation) { graphql_mutation(:merge_request_accept, input, 'mergeRequest { state }') } + let(:mutation_response) { graphql_mutation_response(:merge_request_accept) } + + context 'when the user is not allowed to accept a merge request' do + before do + project.add_reporter(current_user) + end + + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'when user has permissions to create a merge request' do + before do + project.add_maintainer(current_user) + end + + it 'merges the merge request' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['mergeRequest']).to include( + 'state' => 'merged' + ) + end + end +end diff --git a/spec/requests/api/graphql/mutations/notes/create/diff_note_spec.rb b/spec/requests/api/graphql/mutations/notes/create/diff_note_spec.rb index 7dd897f6466..b5aaf304812 100644 --- a/spec/requests/api/graphql/mutations/notes/create/diff_note_spec.rb +++ b/spec/requests/api/graphql/mutations/notes/create/diff_note_spec.rb @@ -9,8 +9,9 @@ RSpec.describe 'Adding a DiffNote' do let(:noteable) { create(:merge_request, source_project: project, target_project: project) } let(:project) { create(:project, :repository) } let(:diff_refs) { noteable.diff_refs } - let(:mutation) do - variables = { + + let(:base_variables) do + { noteable_id: GitlabSchema.id_from_object(noteable).to_s, body: 'Body text', position: { @@ -18,16 +19,16 @@ RSpec.describe 'Adding a DiffNote' do old_path: 'files/ruby/popen.rb', new_path: 'files/ruby/popen2.rb' }, - new_line: 14, base_sha: diff_refs.base_sha, head_sha: diff_refs.head_sha, start_sha: diff_refs.start_sha } } - - graphql_mutation(:create_diff_note, variables) end + let(:variables) { base_variables.deep_merge({ position: { new_line: 14 } }) } + let(:mutation) { graphql_mutation(:create_diff_note, variables) } + def mutation_response graphql_mutation_response(:create_diff_note) end @@ -41,6 +42,18 @@ RSpec.describe 'Adding a DiffNote' do it_behaves_like 'a Note mutation that creates a Note' + context 'add comment to old line' do + let(:variables) { base_variables.deep_merge({ position: { old_line: 14 } }) } + + it_behaves_like 'a Note mutation that creates a Note' + end + + context 'add a comment with a position without lines' do + let(:variables) { base_variables } + + it_behaves_like 'a Note mutation that does not create a Note' + end + it_behaves_like 'a Note mutation when there are active record validation errors', model: DiffNote it_behaves_like 'a Note mutation when there are rate limit validation errors' diff --git a/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb b/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb new file mode 100644 index 00000000000..c7a4cb1ebce --- /dev/null +++ b/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Creation of a new release asset link' do + include GraphqlHelpers + + let_it_be(:project) { create(:project, :private, :repository) } + let_it_be(:release) { create(:release, project: project, tag: 'v13.10') } + let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } } + + let(:current_user) { developer } + + let(:mutation_name) { :release_asset_link_create } + + let(:mutation_arguments) do + { + projectPath: project.full_path, + tagName: release.tag, + name: 'awesome-app.dmg', + url: 'https://example.com/download/awesome-app.dmg', + directAssetPath: '/binaries/awesome-app.dmg', + linkType: 'PACKAGE' + } + end + + let(:mutation) do + graphql_mutation(mutation_name, mutation_arguments, <<~FIELDS) + link { + id + name + url + linkType + directAssetUrl + external + } + errors + FIELDS + end + + let(:create_link) { post_graphql_mutation(mutation, current_user: current_user) } + let(:mutation_response) { graphql_mutation_response(mutation_name)&.with_indifferent_access } + + it 'creates and returns a new asset link associated to the provided release', :aggregate_failures do + create_link + + expected_response = { + id: start_with("gid://gitlab/Releases::Link/"), + name: mutation_arguments[:name], + url: mutation_arguments[:url], + linkType: mutation_arguments[:linkType], + directAssetUrl: end_with(mutation_arguments[:directAssetPath]), + external: true + }.with_indifferent_access + + expect(mutation_response[:link]).to include(expected_response) + expect(mutation_response[:errors]).to eq([]) + end +end diff --git a/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb b/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb new file mode 100644 index 00000000000..92b558d4be3 --- /dev/null +++ b/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Updating an existing release asset link' do + include GraphqlHelpers + + let_it_be(:project) { create(:project, :private, :repository) } + let_it_be(:release) { create(:release, project: project) } + let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } } + + let_it_be(:release_link) do + create(:release_link, + release: release, + name: 'link name', + url: 'https://example.com/url', + filepath: '/permanent/path', + link_type: 'package') + end + + let(:current_user) { developer } + + let(:mutation_name) { :release_asset_link_update } + + let(:mutation_arguments) do + { + id: release_link.to_global_id.to_s, + name: 'updated name', + url: 'https://example.com/updated', + directAssetPath: '/updated/path', + linkType: 'IMAGE' + } + end + + let(:mutation) do + graphql_mutation(mutation_name, mutation_arguments, <<~FIELDS) + link { + id + name + url + linkType + directAssetUrl + external + } + errors + FIELDS + end + + let(:update_link) { post_graphql_mutation(mutation, current_user: current_user) } + let(:mutation_response) { graphql_mutation_response(mutation_name)&.with_indifferent_access } + + it 'updates and existing release asset link and returns the updated link', :aggregate_failures do + update_link + + expected_response = { + id: mutation_arguments[:id], + name: mutation_arguments[:name], + url: mutation_arguments[:url], + linkType: mutation_arguments[:linkType], + directAssetUrl: end_with(mutation_arguments[:directAssetPath]), + external: true + }.with_indifferent_access + + expect(mutation_response[:link]).to include(expected_response) + expect(mutation_response[:errors]).to eq([]) + end +end diff --git a/spec/requests/api/graphql/mutations/user_callouts/create_spec.rb b/spec/requests/api/graphql/mutations/user_callouts/create_spec.rb new file mode 100644 index 00000000000..cb67a60ebe4 --- /dev/null +++ b/spec/requests/api/graphql/mutations/user_callouts/create_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Create a user callout' do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let(:feature_name) { ::UserCallout.feature_names.each_key.first } + + let(:input) do + { + 'featureName' => feature_name + } + end + + let(:mutation) { graphql_mutation(:userCalloutCreate, input) } + let(:mutation_response) { graphql_mutation_response(:userCalloutCreate) } + + it 'creates user callout' do + freeze_time do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['userCallout']['featureName']).to eq(feature_name.upcase) + expect(mutation_response['userCallout']['dismissedAt']).to eq(Time.current.iso8601) + end + end +end diff --git a/spec/requests/api/graphql/namespace/projects_spec.rb b/spec/requests/api/graphql/namespace/projects_spec.rb index 3e68503b7fb..414847c9c93 100644 --- a/spec/requests/api/graphql/namespace/projects_spec.rb +++ b/spec/requests/api/graphql/namespace/projects_spec.rb @@ -23,7 +23,7 @@ RSpec.describe 'getting projects' do projects(includeSubgroups: #{include_subgroups}) { edges { node { - #{all_graphql_fields_for('Project')} + #{all_graphql_fields_for('Project', max_depth: 1)} } } } diff --git a/spec/requests/api/graphql/packages/package_spec.rb b/spec/requests/api/graphql/packages/package_spec.rb index bb3ceb81f16..654215041cb 100644 --- a/spec/requests/api/graphql/packages/package_spec.rb +++ b/spec/requests/api/graphql/packages/package_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'package details' do end let(:depth) { 3 } - let(:excluded) { %w[metadata apiFuzzingCiConfiguration] } + let(:excluded) { %w[metadata apiFuzzingCiConfiguration pipeline] } let(:query) do graphql_query_for(:package, { id: package_global_id }, <<~FIELDS) diff --git a/spec/requests/api/graphql/project/alert_management/alert/issue_spec.rb b/spec/requests/api/graphql/project/alert_management/alert/issue_spec.rb new file mode 100644 index 00000000000..9724de4fedb --- /dev/null +++ b/spec/requests/api/graphql/project/alert_management/alert/issue_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'getting Alert Management Alert Issue' do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:current_user) { create(:user) } + let(:payload) { {} } + let(:query) { 'avg(metric) > 1.0' } + + let(:fields) do + <<~QUERY + nodes { + iid + issue { + iid + state + } + } + QUERY + end + + let(:graphql_query) do + graphql_query_for( + 'project', + { 'fullPath' => project.full_path }, + query_graphql_field('alertManagementAlerts', {}, fields) + ) + end + + let(:alerts) { graphql_data.dig('project', 'alertManagementAlerts', 'nodes') } + let(:first_alert) { alerts.first } + + before do + project.add_developer(current_user) + end + + context 'with gitlab alert' do + before do + create(:alert_management_alert, :with_issue, project: project, payload: payload) + end + + it 'includes the correct alert issue payload data' do + post_graphql(graphql_query, current_user: current_user) + + expect(first_alert).to include('issue' => { "iid" => "1", "state" => "opened" }) + end + end + + describe 'performance' do + let(:first_n) { var('Int') } + let(:params) { { first: first_n } } + let(:limited_query) { with_signature([first_n], query) } + + context 'with gitlab alert' do + before do + create(:alert_management_alert, :with_issue, project: project, payload: payload) + end + + it 'avoids N+1 queries' do + base_count = ActiveRecord::QueryRecorder.new do + post_graphql(limited_query, current_user: current_user, variables: first_n.with(1)) + end + + expect { post_graphql(limited_query, current_user: current_user) }.not_to exceed_query_limit(base_count) + end + end + end +end diff --git a/spec/requests/api/graphql/project/alert_management/alerts_spec.rb b/spec/requests/api/graphql/project/alert_management/alerts_spec.rb index 8deed75a466..fe77d9dc86d 100644 --- a/spec/requests/api/graphql/project/alert_management/alerts_spec.rb +++ b/spec/requests/api/graphql/project/alert_management/alerts_spec.rb @@ -7,7 +7,7 @@ RSpec.describe 'getting Alert Management Alerts' do let_it_be(:payload) { { 'custom' => { 'alert' => 'payload' }, 'runbook' => 'runbook' } } let_it_be(:project) { create(:project, :repository) } let_it_be(:current_user) { create(:user) } - let_it_be(:resolved_alert) { create(:alert_management_alert, :all_fields, :resolved, project: project, issue: nil, severity: :low).present } + let_it_be(:resolved_alert) { create(:alert_management_alert, :all_fields, :resolved, project: project, severity: :low).present } let_it_be(:triggered_alert) { create(:alert_management_alert, :all_fields, project: project, severity: :critical, payload: payload).present } let_it_be(:other_project_alert) { create(:alert_management_alert, :all_fields).present } @@ -60,7 +60,6 @@ RSpec.describe 'getting Alert Management Alerts' do it 'returns the correct properties of the alerts' do expect(first_alert).to include( 'iid' => triggered_alert.iid.to_s, - 'issueIid' => triggered_alert.issue_iid.to_s, 'title' => triggered_alert.title, 'description' => triggered_alert.description, 'severity' => triggered_alert.severity.upcase, @@ -82,7 +81,6 @@ RSpec.describe 'getting Alert Management Alerts' do expect(second_alert).to include( 'iid' => resolved_alert.iid.to_s, - 'issueIid' => resolved_alert.issue_iid.to_s, 'status' => 'RESOLVED', 'endedAt' => resolved_alert.ended_at.strftime('%Y-%m-%dT%H:%M:%SZ') ) diff --git a/spec/requests/api/graphql/project/container_repositories_spec.rb b/spec/requests/api/graphql/project/container_repositories_spec.rb index 2087d8c2cc3..5ffd48a7bc4 100644 --- a/spec/requests/api/graphql/project/container_repositories_spec.rb +++ b/spec/requests/api/graphql/project/container_repositories_spec.rb @@ -16,7 +16,7 @@ RSpec.describe 'getting container repositories in a project' do <<~GQL edges { node { - #{all_graphql_fields_for('container_repositories'.classify)} + #{all_graphql_fields_for('container_repositories'.classify, excluded: ['pipeline'])} } } GQL diff --git a/spec/requests/api/graphql/project/merge_request/diff_notes_spec.rb b/spec/requests/api/graphql/project/merge_request/diff_notes_spec.rb index dd16b052e0e..b1ecb32b365 100644 --- a/spec/requests/api/graphql/project/merge_request/diff_notes_spec.rb +++ b/spec/requests/api/graphql/project/merge_request/diff_notes_spec.rb @@ -34,7 +34,7 @@ RSpec.describe 'getting notes for a merge request' do notes { edges { node { - #{all_graphql_fields_for('Note')} + #{all_graphql_fields_for('Note', excluded: ['pipeline'])} } } } diff --git a/spec/requests/api/graphql/project/merge_request_spec.rb b/spec/requests/api/graphql/project/merge_request_spec.rb index a4e8d0bc35e..e32899c600e 100644 --- a/spec/requests/api/graphql/project/merge_request_spec.rb +++ b/spec/requests/api/graphql/project/merge_request_spec.rb @@ -9,7 +9,7 @@ RSpec.describe 'getting merge request information nested in a project' do let(:current_user) { create(:user) } let(:merge_request_graphql_data) { graphql_data['project']['mergeRequest'] } let!(:merge_request) { create(:merge_request, source_project: project) } - let(:mr_fields) { all_graphql_fields_for('MergeRequest') } + let(:mr_fields) { all_graphql_fields_for('MergeRequest', excluded: ['pipeline']) } let(:query) do graphql_query_for( diff --git a/spec/requests/api/graphql/project/merge_requests_spec.rb b/spec/requests/api/graphql/project/merge_requests_spec.rb index d684be91dc9..d97a0ed9399 100644 --- a/spec/requests/api/graphql/project/merge_requests_spec.rb +++ b/spec/requests/api/graphql/project/merge_requests_spec.rb @@ -7,13 +7,27 @@ RSpec.describe 'getting merge request listings nested in a project' do let_it_be(:project) { create(:project, :repository, :public) } let_it_be(:current_user) { create(:user) } - let_it_be(:label) { create(:label, project: project) } - let_it_be(:merge_request_a) { create(:labeled_merge_request, :unique_branches, source_project: project, labels: [label]) } - let_it_be(:merge_request_b) { create(:merge_request, :closed, :unique_branches, source_project: project) } - let_it_be(:merge_request_c) { create(:labeled_merge_request, :closed, :unique_branches, source_project: project, labels: [label]) } - let_it_be(:merge_request_d) { create(:merge_request, :locked, :unique_branches, source_project: project) } - let_it_be(:merge_request_e) { create(:merge_request, :unique_branches, source_project: project) } + + let_it_be(:merge_request_a) do + create(:labeled_merge_request, :unique_branches, source_project: project, labels: [label]) + end + + let_it_be(:merge_request_b) do + create(:merge_request, :closed, :unique_branches, source_project: project) + end + + let_it_be(:merge_request_c) do + create(:labeled_merge_request, :closed, :unique_branches, source_project: project, labels: [label]) + end + + let_it_be(:merge_request_d) do + create(:merge_request, :locked, :unique_branches, source_project: project) + end + + let_it_be(:merge_request_e) do + create(:merge_request, :unique_branches, source_project: project) + end let(:results) { graphql_data.dig('project', 'mergeRequests', 'nodes') } @@ -27,32 +41,38 @@ RSpec.describe 'getting merge request listings nested in a project' do ) end - let(:query) do - query_merge_requests(all_graphql_fields_for('MergeRequest', max_depth: 1)) - end - it_behaves_like 'a working graphql query' do + let(:query) do + query_merge_requests(all_graphql_fields_for('MergeRequest', max_depth: 2)) + end + before do - post_graphql(query, current_user: current_user) + # We cannot call the whitelist here, since the transaction does not + # begin until we enter the controller. + headers = { + 'X-GITLAB-QUERY-WHITELIST-ISSUE' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/322979' + } + + post_graphql(query, current_user: current_user, headers: headers) end end # The following tests are needed to guarantee that we have correctly annotated # all the gitaly calls. Selecting combinations of fields may mask this due to # memoization. - context 'requesting a single field' do + context 'when requesting a single field' do let_it_be(:fresh_mr) { create(:merge_request, :unique_branches, source_project: project) } + let(:search_params) { { iids: [fresh_mr.iid.to_s] } } + let(:graphql_data) do + GitlabSchema.execute(query, context: { current_user: current_user }).to_h['data'] + end before do project.repository.expire_branches_cache end - let(:graphql_data) do - GitlabSchema.execute(query, context: { current_user: current_user }).to_h['data'] - end - - context 'selecting any single scalar field' do + context 'when selecting any single scalar field' do where(:field) do scalar_fields_of('MergeRequest').map { |name| [name] } end @@ -68,7 +88,7 @@ RSpec.describe 'getting merge request listings nested in a project' do end end - context 'selecting any single nested field' do + context 'when selecting any single nested field' do where(:field, :subfield, :is_connection) do nested_fields_of('MergeRequest').flat_map do |name, field| type = field_type(field) @@ -95,7 +115,11 @@ RSpec.describe 'getting merge request listings nested in a project' do end end - shared_examples 'searching with parameters' do + shared_examples 'when searching with parameters' do + let(:query) do + query_merge_requests('iid title') + end + let(:expected) do mrs.map { |mr| a_hash_including('iid' => mr.iid.to_s, 'title' => mr.title) } end @@ -107,60 +131,60 @@ RSpec.describe 'getting merge request listings nested in a project' do end end - context 'there are no search params' do + context 'when there are no search params' do let(:search_params) { nil } let(:mrs) { [merge_request_a, merge_request_b, merge_request_c, merge_request_d, merge_request_e] } - it_behaves_like 'searching with parameters' + it_behaves_like 'when searching with parameters' end - context 'the search params do not match anything' do - let(:search_params) { { iids: %w(foo bar baz) } } + context 'when the search params do not match anything' do + let(:search_params) { { iids: %w[foo bar baz] } } let(:mrs) { [] } - it_behaves_like 'searching with parameters' + it_behaves_like 'when searching with parameters' end - context 'searching by iids' do + context 'when searching by iids' do let(:search_params) { { iids: mrs.map(&:iid).map(&:to_s) } } let(:mrs) { [merge_request_a, merge_request_c] } - it_behaves_like 'searching with parameters' + it_behaves_like 'when searching with parameters' end - context 'searching by state' do + context 'when searching by state' do let(:search_params) { { state: :closed } } let(:mrs) { [merge_request_b, merge_request_c] } - it_behaves_like 'searching with parameters' + it_behaves_like 'when searching with parameters' end - context 'searching by source_branch' do + context 'when searching by source_branch' do let(:search_params) { { source_branches: mrs.map(&:source_branch) } } let(:mrs) { [merge_request_b, merge_request_c] } - it_behaves_like 'searching with parameters' + it_behaves_like 'when searching with parameters' end - context 'searching by target_branch' do + context 'when searching by target_branch' do let(:search_params) { { target_branches: mrs.map(&:target_branch) } } let(:mrs) { [merge_request_a, merge_request_d] } - it_behaves_like 'searching with parameters' + it_behaves_like 'when searching with parameters' end - context 'searching by label' do + context 'when searching by label' do let(:search_params) { { labels: [label.title] } } let(:mrs) { [merge_request_a, merge_request_c] } - it_behaves_like 'searching with parameters' + it_behaves_like 'when searching with parameters' end - context 'searching by combination' do + context 'when searching by combination' do let(:search_params) { { state: :closed, labels: [label.title] } } let(:mrs) { [merge_request_c] } - it_behaves_like 'searching with parameters' + it_behaves_like 'when searching with parameters' end context 'when requesting `approved_by`' do @@ -203,10 +227,10 @@ RSpec.describe 'getting merge request listings nested in a project' do it 'exposes `commit_count`' do execute_query - expect(results).to match_array([ + expect(results).to match_array [ { "iid" => merge_request_a.iid.to_s, "commitCount" => 0 }, { "iid" => merge_request_with_commits.iid.to_s, "commitCount" => 29 } - ]) + ] end end @@ -216,8 +240,8 @@ RSpec.describe 'getting merge request listings nested in a project' do before do # make the MRs "merged" [merge_request_a, merge_request_b, merge_request_c].each do |mr| - mr.update_column(:state_id, MergeRequest.available_states[:merged]) - mr.metrics.update_column(:merged_at, Time.now) + mr.update!(state_id: MergeRequest.available_states[:merged]) + mr.metrics.update!(merged_at: Time.now) end end @@ -256,13 +280,12 @@ RSpec.describe 'getting merge request listings nested in a project' do end it 'returns the reviewers' do + nodes = merge_request_a.reviewers.map { |r| { 'username' => r.username } } + reviewers = { 'nodes' => match_array(nodes) } + execute_query - expect(results).to include a_hash_including('reviewers' => { - 'nodes' => match_array(merge_request_a.reviewers.map do |r| - a_hash_including('username' => r.username) - end) - }) + expect(results).to include a_hash_including('reviewers' => match(reviewers)) end include_examples 'N+1 query check' @@ -309,12 +332,14 @@ RSpec.describe 'getting merge request listings nested in a project' do allow(Gitlab::Database).to receive(:read_only?).and_return(false) end + def query_context + { current_user: current_user } + end + def run_query(number) # Ensure that we have a fresh request store and batch-context between runs - result = run_with_clean_state(query, - context: { current_user: current_user }, - variables: { first: number } - ) + vars = { first: number } + result = run_with_clean_state(query, context: query_context, variables: vars) graphql_dig_at(result.to_h, :data, :project, :merge_requests, :nodes) end @@ -348,39 +373,49 @@ RSpec.describe 'getting merge request listings nested in a project' do let(:data_path) { [:project, :mergeRequests] } def pagination_query(params) - graphql_query_for(:project, { full_path: project.full_path }, - <<~QUERY + graphql_query_for(:project, { full_path: project.full_path }, <<~QUERY) mergeRequests(#{params}) { #{page_info} nodes { id } } - QUERY - ) + QUERY end context 'when sorting by merged_at DESC' do - it_behaves_like 'sorted paginated query' do - let(:sort_param) { :MERGED_AT_DESC } - let(:first_param) { 2 } + let(:sort_param) { :MERGED_AT_DESC } + let(:expected_results) do + [ + merge_request_b, + merge_request_d, + merge_request_c, + merge_request_e, + merge_request_a + ].map { |mr| global_id_of(mr) } + end - let(:expected_results) do - [ - merge_request_b, - merge_request_d, - merge_request_c, - merge_request_e, - merge_request_a - ].map { |mr| global_id_of(mr) } - end + before do + five_days_ago = 5.days.ago - before do - five_days_ago = 5.days.ago + merge_request_d.metrics.update!(merged_at: five_days_ago) + + # same merged_at, the second order column will decide (merge_request.id) + merge_request_c.metrics.update!(merged_at: five_days_ago) + + merge_request_b.metrics.update!(merged_at: 1.day.ago) + end + + it_behaves_like 'sorted paginated query' do + let(:first_param) { 2 } + end - merge_request_d.metrics.update!(merged_at: five_days_ago) + context 'when last parameter is given' do + let(:params) { graphql_args(sort: sort_param, last: 2) } + let(:page_info) { nil } - # same merged_at, the second order column will decide (merge_request.id) - merge_request_c.metrics.update!(merged_at: five_days_ago) + it 'takes the last 2 records' do + query = pagination_query(params) + post_graphql(query, current_user: current_user) - merge_request_b.metrics.update!(merged_at: 1.day.ago) + expect(results.map { |item| item["id"] }).to eq(expected_results.last(2)) end end end @@ -396,75 +431,45 @@ RSpec.describe 'getting merge request listings nested in a project' do let(:query) do # Note: __typename meta field is always requested by the FE - graphql_query_for(:project, { full_path: project.full_path }, - <<~QUERY + graphql_query_for(:project, { full_path: project.full_path }, <<~QUERY) mergeRequests(mergedAfter: "2020-01-01", mergedBefore: "2020-01-05", first: 0, sourceBranches: null, labels: null) { count __typename } QUERY - ) end - shared_examples 'count examples' do - it 'returns the correct count' do - post_graphql(query, current_user: current_user) + it 'does not query the merge requests table for the count' do + query_recorder = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) } - count = graphql_data.dig('project', 'mergeRequests', 'count') - expect(count).to eq(1) - end + queries = query_recorder.data.each_value.first[:occurrences] + expect(queries).not_to include(match(/SELECT COUNT\(\*\) FROM "merge_requests"/)) + expect(queries).to include(match(/SELECT COUNT\(\*\) FROM "merge_request_metrics"/)) end - context 'when "optimized_merge_request_count_with_merged_at_filter" feature flag is enabled' do - before do - stub_feature_flags(optimized_merge_request_count_with_merged_at_filter: true) + context 'when total_time_to_merge and count is queried' do + let(:query) do + graphql_query_for(:project, { full_path: project.full_path }, <<~QUERY) + mergeRequests(mergedAfter: "2020-01-01", mergedBefore: "2020-01-05", first: 0) { + totalTimeToMerge + count + } + QUERY end - it 'does not query the merge requests table for the count' do + it 'does not query the merge requests table for the total_time_to_merge' do query_recorder = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) } queries = query_recorder.data.each_value.first[:occurrences] - expect(queries).not_to include(match(/SELECT COUNT\(\*\) FROM "merge_requests"/)) - expect(queries).to include(match(/SELECT COUNT\(\*\) FROM "merge_request_metrics"/)) + expect(queries).to include(match(/SELECT.+SUM.+FROM "merge_request_metrics" WHERE/)) end + end - context 'when total_time_to_merge and count is queried' do - let(:query) do - graphql_query_for(:project, { full_path: project.full_path }, - <<~QUERY - mergeRequests(mergedAfter: "2020-01-01", mergedBefore: "2020-01-05", first: 0) { - totalTimeToMerge - count - } - QUERY - ) - end - - it 'does not query the merge requests table for the total_time_to_merge' do - query_recorder = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) } - - queries = query_recorder.data.each_value.first[:occurrences] - expect(queries).to include(match(/SELECT.+SUM.+FROM "merge_request_metrics" WHERE/)) - end - end - - it_behaves_like 'count examples' - - context 'when "optimized_merge_request_count_with_merged_at_filter" feature flag is disabled' do - before do - stub_feature_flags(optimized_merge_request_count_with_merged_at_filter: false) - end - - it 'queries the merge requests table for the count' do - query_recorder = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) } - - queries = query_recorder.data.each_value.first[:occurrences] - expect(queries).to include(match(/SELECT COUNT\(\*\) FROM "merge_requests"/)) - expect(queries).not_to include(match(/SELECT COUNT\(\*\) FROM "merge_request_metrics"/)) - end + it 'returns the correct count' do + post_graphql(query, current_user: current_user) - it_behaves_like 'count examples' - end + count = graphql_data.dig('project', 'mergeRequests', 'count') + expect(count).to eq(1) end end end diff --git a/spec/requests/api/graphql/project/packages_spec.rb b/spec/requests/api/graphql/project/packages_spec.rb index b20c96d54c8..3c04e0caf61 100644 --- a/spec/requests/api/graphql/project/packages_spec.rb +++ b/spec/requests/api/graphql/project/packages_spec.rb @@ -5,28 +5,28 @@ require 'spec_helper' RSpec.describe 'getting a package list for a project' do include GraphqlHelpers - let_it_be(:project) { create(:project, :repository) } + let_it_be(:resource) { create(:project, :repository) } let_it_be(:current_user) { create(:user) } - let_it_be(:package) { create(:package, project: project) } - let_it_be(:maven_package) { create(:maven_package, project: project) } - let_it_be(:debian_package) { create(:debian_package, project: project) } - let_it_be(:composer_package) { create(:composer_package, project: project) } + let_it_be(:package) { create(:package, project: resource) } + let_it_be(:maven_package) { create(:maven_package, project: resource) } + let_it_be(:debian_package) { create(:debian_package, project: resource) } + let_it_be(:composer_package) { create(:composer_package, project: resource) } let_it_be(:composer_metadatum) do create(:composer_metadatum, package: composer_package, target_sha: 'afdeh', composer_json: { name: 'x', type: 'y', license: 'z', version: 1 }) end - let(:package_names) { graphql_data_at(:project, :packages, :edges, :node, :name) } + let(:package_names) { graphql_data_at(:project, :packages, :nodes, :name) } + let(:target_shas) { graphql_data_at(:project, :packages, :nodes, :metadata, :target_sha) } + let(:packages) { graphql_data_at(:project, :packages, :nodes) } let(:fields) do <<~QUERY - edges { - node { - #{all_graphql_fields_for('packages'.classify, excluded: ['project'])} - metadata { #{query_graphql_fragment('ComposerMetadata')} } - } + nodes { + #{all_graphql_fields_for('packages'.classify, excluded: ['project'])} + metadata { #{query_graphql_fragment('ComposerMetadata')} } } QUERY end @@ -34,55 +34,10 @@ RSpec.describe 'getting a package list for a project' do let(:query) do graphql_query_for( 'project', - { 'fullPath' => project.full_path }, + { 'fullPath' => resource.full_path }, query_graphql_field('packages', {}, fields) ) end - context 'when user has access to the project' do - before do - project.add_reporter(current_user) - post_graphql(query, current_user: current_user) - end - - it_behaves_like 'a working graphql query' - - it 'returns packages successfully' do - expect(package_names).to contain_exactly( - package.name, - maven_package.name, - debian_package.name, - composer_package.name - ) - end - - it 'deals with metadata' do - target_shas = graphql_data_at(:project, :packages, :edges, :node, :metadata, :target_sha) - expect(target_shas).to contain_exactly(composer_metadatum.target_sha) - end - end - - context 'when the user does not have access to the project/packages' do - before do - post_graphql(query, current_user: current_user) - end - - it_behaves_like 'a working graphql query' - - it 'returns nil' do - expect(graphql_data['project']).to be_nil - end - end - - context 'when the user is not authenticated' do - before do - post_graphql(query) - end - - it_behaves_like 'a working graphql query' - - it 'returns nil' do - expect(graphql_data['project']).to be_nil - end - end + it_behaves_like 'group and project packages query' end diff --git a/spec/requests/api/graphql/project/pipeline_spec.rb b/spec/requests/api/graphql/project/pipeline_spec.rb index 6179b43629b..cc028ff2ff9 100644 --- a/spec/requests/api/graphql/project/pipeline_spec.rb +++ b/spec/requests/api/graphql/project/pipeline_spec.rb @@ -11,10 +11,14 @@ RSpec.describe 'getting pipeline information nested in a project' do let(:pipeline_graphql_data) { graphql_data['project']['pipeline'] } let!(:query) do - graphql_query_for( - 'project', - { 'fullPath' => project.full_path }, - query_graphql_field('pipeline', iid: pipeline.iid.to_s) + %( + query { + project(fullPath: "#{project.full_path}") { + pipeline(iid: "#{pipeline.iid}") { + configSource + } + } + } ) end diff --git a/spec/requests/api/graphql/snippets_spec.rb b/spec/requests/api/graphql/snippets_spec.rb new file mode 100644 index 00000000000..9edd805678a --- /dev/null +++ b/spec/requests/api/graphql/snippets_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'snippets' do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:snippets) { create_list(:personal_snippet, 3, :repository, author: current_user) } + + describe 'querying for all fields' do + let(:query) do + graphql_query_for(:snippets, { ids: [global_id_of(snippets.first)] }, <<~SELECT) + nodes { #{all_graphql_fields_for('Snippet')} } + SELECT + end + + it 'can successfully query for snippets and their blobs' do + post_graphql(query, current_user: current_user) + + expect(graphql_data_at(:snippets, :nodes)).to be_one + expect(graphql_data_at(:snippets, :nodes, :blobs, :nodes)).to be_present + end + end +end diff --git a/spec/requests/api/graphql/instance_statistics_measurements_spec.rb b/spec/requests/api/graphql/usage_trends_measurements_spec.rb index eb73dc59253..69a3ed7e09c 100644 --- a/spec/requests/api/graphql/instance_statistics_measurements_spec.rb +++ b/spec/requests/api/graphql/usage_trends_measurements_spec.rb @@ -2,22 +2,22 @@ require 'spec_helper' -RSpec.describe 'InstanceStatisticsMeasurements' do +RSpec.describe 'UsageTrendsMeasurements' do include GraphqlHelpers let(:current_user) { create(:user, :admin) } - let!(:instance_statistics_measurement_1) { create(:instance_statistics_measurement, :project_count, recorded_at: 20.days.ago, count: 5) } - let!(:instance_statistics_measurement_2) { create(:instance_statistics_measurement, :project_count, recorded_at: 10.days.ago, count: 10) } + let!(:usage_trends_measurement_1) { create(:usage_trends_measurement, :project_count, recorded_at: 20.days.ago, count: 5) } + let!(:usage_trends_measurement_2) { create(:usage_trends_measurement, :project_count, recorded_at: 10.days.ago, count: 10) } let(:arguments) { 'identifier: PROJECTS' } - let(:query) { graphql_query_for(:instanceStatisticsMeasurements, arguments, 'nodes { count identifier }') } + let(:query) { graphql_query_for(:UsageTrendsMeasurements, arguments, 'nodes { count identifier }') } before do post_graphql(query, current_user: current_user) end it 'returns measurement objects' do - expect(graphql_data.dig('instanceStatisticsMeasurements', 'nodes')).to eq([ + expect(graphql_data.dig('usageTrendsMeasurements', 'nodes')).to eq([ { "count" => 10, 'identifier' => 'PROJECTS' }, { "count" => 5, 'identifier' => 'PROJECTS' } ]) @@ -27,7 +27,7 @@ RSpec.describe 'InstanceStatisticsMeasurements' do let(:arguments) { %(identifier: PROJECTS, recordedAfter: "#{15.days.ago.to_date}", recordedBefore: "#{5.days.ago.to_date}") } it 'returns filtered measurement objects' do - expect(graphql_data.dig('instanceStatisticsMeasurements', 'nodes')).to eq([ + expect(graphql_data.dig('usageTrendsMeasurements', 'nodes')).to eq([ { "count" => 10, 'identifier' => 'PROJECTS' } ]) end diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb index 91d10791541..8160a94aef2 100644 --- a/spec/requests/api/helpers_spec.rb +++ b/spec/requests/api/helpers_spec.rb @@ -314,14 +314,13 @@ RSpec.describe API::Helpers do expect(Gitlab::ErrorTracking).to receive(:sentry_dsn).and_return(Gitlab.config.sentry.dsn) Gitlab::ErrorTracking.configure - Raven.client.configuration.encoding = 'json' end it 'does not report a MethodNotAllowed exception to Sentry' do exception = Grape::Exceptions::MethodNotAllowed.new({ 'X-GitLab-Test' => '1' }) allow(exception).to receive(:backtrace).and_return(caller) - expect(Raven).not_to receive(:capture_exception).with(exception) + expect(Gitlab::ErrorTracking).not_to receive(:track_exception).with(exception) handle_api_exception(exception) end @@ -330,8 +329,7 @@ RSpec.describe API::Helpers do exception = RuntimeError.new('test error') allow(exception).to receive(:backtrace).and_return(caller) - expect(Raven).to receive(:capture_exception).with(exception, tags: - a_hash_including(correlation_id: 'new-correlation-id'), extra: {}) + expect(Gitlab::ErrorTracking).to receive(:track_exception).with(exception) Labkit::Correlation::CorrelationId.use_id('new-correlation-id') do handle_api_exception(exception) @@ -357,20 +355,6 @@ RSpec.describe API::Helpers do expect(json_response['message']).to start_with("\nRuntimeError (Runtime Error!):") end end - - context 'extra information' do - # Sentry events are an array of the form [auth_header, data, options] - let(:event_data) { Raven.client.transport.events.first[1] } - - it 'sends the params, excluding confidential values' do - expect(ProjectsFinder).to receive(:new).and_raise('Runtime Error!') - - get api('/projects', user), params: { password: 'dont_send_this', other_param: 'send_this' } - - expect(event_data).to include('other_param=send_this') - expect(event_data).to include('password=********') - end - end end describe '.authenticate_non_get!' do diff --git a/spec/requests/api/invitations_spec.rb b/spec/requests/api/invitations_spec.rb index 2ea237469b1..98a7aa63b16 100644 --- a/spec/requests/api/invitations_spec.rb +++ b/spec/requests/api/invitations_spec.rb @@ -3,14 +3,14 @@ require 'spec_helper' RSpec.describe API::Invitations do - let(:maintainer) { create(:user, username: 'maintainer_user') } - let(:developer) { create(:user) } - let(:access_requester) { create(:user) } - let(:stranger) { create(:user) } + let_it_be(:maintainer) { create(:user, username: 'maintainer_user') } + let_it_be(:developer) { create(:user) } + let_it_be(:access_requester) { create(:user) } + let_it_be(:stranger) { create(:user) } let(:email) { 'email1@example.com' } let(:email2) { 'email2@example.com' } - let(:project) do + let_it_be(:project) do create(:project, :public, creator_id: maintainer.id, namespace: maintainer.namespace) do |project| project.add_developer(developer) project.add_maintainer(maintainer) @@ -18,7 +18,7 @@ RSpec.describe API::Invitations do end end - let!(:group) do + let_it_be(:group, reload: true) do create(:group, :public) do |group| group.add_developer(developer) group.add_owner(maintainer) @@ -374,4 +374,104 @@ RSpec.describe API::Invitations do let(:source) { group } end end + + shared_examples 'PUT /:source_type/:id/invitations/:email' do |source_type| + def update_api(source, user, email) + api("/#{source.model_name.plural}/#{source.id}/invitations/#{email}", user) + end + + context "with :source_type == #{source_type.pluralize}" do + let!(:invite) { invite_member_by_email(source, source_type, developer.email, maintainer) } + + it_behaves_like 'a 404 response when source is private' do + let(:route) do + put update_api(source, stranger, invite.invite_email), params: { access_level: Member::MAINTAINER } + end + end + + context 'when authenticated as a non-member or member with insufficient rights' do + %i[access_requester stranger].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + + put update_api(source, user, invite.invite_email), params: { access_level: Member::MAINTAINER } + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end + end + + context 'when authenticated as a maintainer/owner' do + context 'updating access level' do + it 'updates the invitation' do + put update_api(source, maintainer, invite.invite_email), params: { access_level: Member::MAINTAINER } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['access_level']).to eq(Member::MAINTAINER) + expect(invite.reload.access_level).to eq(Member::MAINTAINER) + end + end + + it 'returns 409 if member does not exist' do + put update_api(source, maintainer, non_existing_record_id), params: { access_level: Member::MAINTAINER } + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns 400 when access_level is not given and there are no other params' do + put update_api(source, maintainer, invite.invite_email) + + expect(response).to have_gitlab_http_status(:bad_request) + end + + it 'returns 400 when access level is not valid' do + put update_api(source, maintainer, invite.invite_email), params: { access_level: non_existing_record_access_level } + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + + context 'updating access expiry date' do + subject do + put update_api(source, maintainer, invite.invite_email), params: { expires_at: expires_at } + end + + context 'when set to a date in the past' do + let(:expires_at) { 2.days.ago.to_date } + + it 'does not update the member' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to eq({ 'expires_at' => ['cannot be a date in the past'] }) + end + end + + context 'when set to a date in the future' do + let(:expires_at) { 2.days.from_now.to_date } + + it 'updates the member' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['expires_at']).to eq(expires_at.to_s) + end + end + end + end + end + + describe 'PUT /projects/:id/invitations' do + it_behaves_like 'PUT /:source_type/:id/invitations/:email', 'project' do + let(:source) { project } + end + end + + describe 'PUT /groups/:id/invitations' do + it_behaves_like 'PUT /:source_type/:id/invitations/:email', 'group' do + let(:source) { group } + end + end end diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb index 1c43ef25f14..fe00b654d3b 100644 --- a/spec/requests/api/jobs_spec.rb +++ b/spec/requests/api/jobs_spec.rb @@ -3,6 +3,9 @@ require 'spec_helper' RSpec.describe API::Jobs do + include HttpBasicAuthHelpers + include DependencyProxyHelpers + using RSpec::Parameterized::TableSyntax include HttpIOHelpers @@ -16,20 +19,151 @@ RSpec.describe API::Jobs do ref: project.default_branch) end - let!(:job) do - create(:ci_build, :success, :tags, pipeline: pipeline, - artifacts_expire_at: 1.day.since) - end - let(:user) { create(:user) } let(:api_user) { user } let(:reporter) { create(:project_member, :reporter, project: project).user } let(:guest) { create(:project_member, :guest, project: project).user } + let(:running_job) do + create(:ci_build, :running, project: project, + user: user, + pipeline: pipeline, + artifacts_expire_at: 1.day.since) + end + + let!(:job) do + create(:ci_build, :success, :tags, pipeline: pipeline, + artifacts_expire_at: 1.day.since) + end + before do project.add_developer(user) end + shared_examples 'returns common pipeline data' do + it 'returns common pipeline data' do + expect(json_response['pipeline']).not_to be_empty + expect(json_response['pipeline']['id']).to eq jobx.pipeline.id + expect(json_response['pipeline']['project_id']).to eq jobx.pipeline.project_id + expect(json_response['pipeline']['ref']).to eq jobx.pipeline.ref + expect(json_response['pipeline']['sha']).to eq jobx.pipeline.sha + expect(json_response['pipeline']['status']).to eq jobx.pipeline.status + end + end + + shared_examples 'returns common job data' do + it 'returns common job data' do + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['id']).to eq(jobx.id) + expect(json_response['status']).to eq(jobx.status) + expect(json_response['stage']).to eq(jobx.stage) + expect(json_response['name']).to eq(jobx.name) + expect(json_response['ref']).to eq(jobx.ref) + expect(json_response['tag']).to eq(jobx.tag) + expect(json_response['coverage']).to eq(jobx.coverage) + expect(json_response['allow_failure']).to eq(jobx.allow_failure) + expect(Time.parse(json_response['created_at'])).to be_like_time(jobx.created_at) + expect(Time.parse(json_response['started_at'])).to be_like_time(jobx.started_at) + expect(Time.parse(json_response['artifacts_expire_at'])).to be_like_time(jobx.artifacts_expire_at) + expect(json_response['artifacts_file']).to be_nil + expect(json_response['artifacts']).to be_an Array + expect(json_response['artifacts']).to be_empty + expect(json_response['web_url']).to be_present + end + end + + shared_examples 'returns unauthorized' do + it 'returns unauthorized' do + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + describe 'GET /job' do + shared_context 'with auth headers' do + let(:headers_with_token) { header } + let(:params_with_token) { {} } + end + + shared_context 'with auth params' do + let(:headers_with_token) { {} } + let(:params_with_token) { param } + end + + shared_context 'without auth' do + let(:headers_with_token) { {} } + let(:params_with_token) { {} } + end + + before do |example| + unless example.metadata[:skip_before_request] + get api('/job'), headers: headers_with_token, params: params_with_token + end + end + + context 'with job token authentication header' do + include_context 'with auth headers' do + let(:header) { { API::Helpers::Runner::JOB_TOKEN_HEADER => running_job.token } } + end + + it_behaves_like 'returns common job data' do + let(:jobx) { running_job } + end + + it 'returns specific job data' do + expect(json_response['finished_at']).to be_nil + end + + it_behaves_like 'returns common pipeline data' do + let(:jobx) { running_job } + end + end + + context 'with job token authentication params' do + include_context 'with auth params' do + let(:param) { { job_token: running_job.token } } + end + + it_behaves_like 'returns common job data' do + let(:jobx) { running_job } + end + + it 'returns specific job data' do + expect(json_response['finished_at']).to be_nil + end + + it_behaves_like 'returns common pipeline data' do + let(:jobx) { running_job } + end + end + + context 'with non running job' do + include_context 'with auth headers' do + let(:header) { { API::Helpers::Runner::JOB_TOKEN_HEADER => job.token } } + end + + it_behaves_like 'returns unauthorized' + end + + context 'with basic auth header' do + let(:personal_access_token) { create(:personal_access_token, user: user) } + let(:token) { personal_access_token.token} + + include_context 'with auth headers' do + let(:header) { { Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER => token } } + end + + it 'does not return a job' do + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'without authentication' do + include_context 'without auth' + + it_behaves_like 'returns unauthorized' + end + end + describe 'GET /projects/:id/jobs' do let(:query) { {} } @@ -150,39 +284,21 @@ RSpec.describe API::Jobs do end context 'authorized user' do + it_behaves_like 'returns common job data' do + let(:jobx) { job } + end + it 'returns specific job data' do - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['id']).to eq(job.id) - expect(json_response['status']).to eq(job.status) - expect(json_response['stage']).to eq(job.stage) - expect(json_response['name']).to eq(job.name) - expect(json_response['ref']).to eq(job.ref) - expect(json_response['tag']).to eq(job.tag) - expect(json_response['coverage']).to eq(job.coverage) - expect(json_response['allow_failure']).to eq(job.allow_failure) - expect(Time.parse(json_response['created_at'])).to be_like_time(job.created_at) - expect(Time.parse(json_response['started_at'])).to be_like_time(job.started_at) expect(Time.parse(json_response['finished_at'])).to be_like_time(job.finished_at) - expect(Time.parse(json_response['artifacts_expire_at'])).to be_like_time(job.artifacts_expire_at) - expect(json_response['artifacts_file']).to be_nil - expect(json_response['artifacts']).to be_an Array - expect(json_response['artifacts']).to be_empty expect(json_response['duration']).to eq(job.duration) - expect(json_response['web_url']).to be_present end it_behaves_like 'a job with artifacts and trace', result_is_array: false do let(:api_endpoint) { "/projects/#{project.id}/jobs/#{second_job.id}" } end - it 'returns pipeline data' do - json_job = json_response - - expect(json_job['pipeline']).not_to be_empty - expect(json_job['pipeline']['id']).to eq job.pipeline.id - expect(json_job['pipeline']['ref']).to eq job.pipeline.ref - expect(json_job['pipeline']['sha']).to eq job.pipeline.sha - expect(json_job['pipeline']['status']).to eq job.pipeline.status + it_behaves_like 'returns common pipeline data' do + let(:jobx) { job } end end @@ -329,6 +445,17 @@ RSpec.describe API::Jobs do .to include('Content-Type' => 'application/json', 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/) end + + context 'when artifacts are locked' do + it 'allows access to expired artifact' do + pipeline.artifacts_locked! + job.update!(artifacts_expire_at: Time.now - 7.days) + + get_artifact_file(artifact) + + expect(response).to have_gitlab_http_status(:ok) + end + end end end diff --git a/spec/requests/api/lint_spec.rb b/spec/requests/api/lint_spec.rb index b5bf697e9e3..cf8cac773f5 100644 --- a/spec/requests/api/lint_spec.rb +++ b/spec/requests/api/lint_spec.rb @@ -406,6 +406,24 @@ RSpec.describe API::Lint do end end + context 'with an empty repository' do + let_it_be(:empty_project) { create(:project_empty_repo) } + let_it_be(:yaml_content) do + File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) + end + + before do + empty_project.add_developer(api_user) + end + + it 'passes validation without errors' do + post api("/projects/#{empty_project.id}/ci/lint", api_user), params: { content: yaml_content } + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['valid']).to eq(true) + expect(json_response['errors']).to eq([]) + end + end + context 'when unauthenticated' do let_it_be(:api_user) { nil } diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index ad8e21bf4c1..09177dd1710 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -2537,7 +2537,7 @@ RSpec.describe API::MergeRequests do end end - describe "the should_remove_source_branch param" do + describe "the should_remove_source_branch param", :sidekiq_inline do let(:source_repository) { merge_request.source_project.repository } let(:source_branch) { merge_request.source_branch } @@ -2552,7 +2552,7 @@ RSpec.describe API::MergeRequests do end end - context "with a merge request that has force_remove_source_branch enabled" do + context "with a merge request that has force_remove_source_branch enabled", :sidekiq_inline do let(:source_repository) { merge_request.source_project.repository } let(:source_branch) { merge_request.source_branch } diff --git a/spec/requests/api/npm_instance_packages_spec.rb b/spec/requests/api/npm_instance_packages_spec.rb index 70c76067a6e..698885ddcf4 100644 --- a/spec/requests/api/npm_instance_packages_spec.rb +++ b/spec/requests/api/npm_instance_packages_spec.rb @@ -3,6 +3,11 @@ require 'spec_helper' RSpec.describe API::NpmInstancePackages do + # We need to create a subgroup with the same name as the hosting group. + # It has to be created first to exhibit this bug: https://gitlab.com/gitlab-org/gitlab/-/issues/321958 + let_it_be(:another_namespace) { create(:group, :public) } + let_it_be(:similarly_named_group) { create(:group, :public, parent: another_namespace, name: 'test-group') } + include_context 'npm api setup' describe 'GET /api/v4/packages/npm/*package_name' do diff --git a/spec/requests/api/npm_project_packages_spec.rb b/spec/requests/api/npm_project_packages_spec.rb index 7ea238c0607..e64b5ddc374 100644 --- a/spec/requests/api/npm_project_packages_spec.rb +++ b/spec/requests/api/npm_project_packages_spec.rb @@ -30,7 +30,7 @@ RSpec.describe API::NpmProjectPackages do end describe 'GET /api/v4/projects/:id/packages/npm/*package_name/-/*file_name' do - let_it_be(:package_file) { package.package_files.first } + let(:package_file) { package.package_files.first } let(:headers) { {} } let(:url) { api("/projects/#{project.id}/packages/npm/#{package.name}/-/#{package_file.file_name}") } @@ -127,24 +127,6 @@ RSpec.describe API::NpmProjectPackages do context 'when params are correct' do context 'invalid package record' do - context 'unscoped package' do - let(:package_name) { 'my_unscoped_package' } - let(:params) { upload_params(package_name: package_name) } - - it_behaves_like 'handling invalid record with 400 error' - - context 'with empty versions' do - let(:params) { upload_params(package_name: package_name).merge!(versions: {}) } - - it 'throws a 400 error' do - expect { upload_package_with_token(package_name, params) } - .not_to change { project.packages.count } - - expect(response).to have_gitlab_http_status(:bad_request) - end - end - end - context 'invalid package name' do let(:package_name) { "@#{group.path}/my_inv@@lid_package_name" } let(:params) { upload_params(package_name: package_name) } @@ -175,52 +157,71 @@ RSpec.describe API::NpmProjectPackages do end end - context 'scoped package' do - let(:package_name) { "@#{group.path}/my_package_name" } + context 'valid package record' do let(:params) { upload_params(package_name: package_name) } - context 'with access token' do - subject { upload_package_with_token(package_name, params) } + shared_examples 'handling upload with different authentications' do + context 'with access token' do + subject { upload_package_with_token(package_name, params) } + + it_behaves_like 'a package tracking event', 'API::NpmPackages', 'push_package' + + it 'creates npm package with file' do + expect { subject } + .to change { project.packages.count }.by(1) + .and change { Packages::PackageFile.count }.by(1) + .and change { Packages::Tag.count }.by(1) - it_behaves_like 'a package tracking event', 'API::NpmPackages', 'push_package' + expect(response).to have_gitlab_http_status(:ok) + end + end - it 'creates npm package with file' do - expect { subject } + it 'creates npm package with file with job token' do + expect { upload_package_with_job_token(package_name, params) } .to change { project.packages.count }.by(1) .and change { Packages::PackageFile.count }.by(1) - .and change { Packages::Tag.count }.by(1) expect(response).to have_gitlab_http_status(:ok) end - end - it 'creates npm package with file with job token' do - expect { upload_package_with_job_token(package_name, params) } - .to change { project.packages.count }.by(1) - .and change { Packages::PackageFile.count }.by(1) + context 'with an authenticated job token' do + let!(:job) { create(:ci_build, user: user) } - expect(response).to have_gitlab_http_status(:ok) - end + before do + Grape::Endpoint.before_each do |endpoint| + expect(endpoint).to receive(:current_authenticated_job) { job } + end + end - context 'with an authenticated job token' do - let!(:job) { create(:ci_build, user: user) } + after do + Grape::Endpoint.before_each nil + end - before do - Grape::Endpoint.before_each do |endpoint| - expect(endpoint).to receive(:current_authenticated_job) { job } + it 'creates the package metadata' do + upload_package_with_token(package_name, params) + + expect(response).to have_gitlab_http_status(:ok) + expect(project.reload.packages.find(json_response['id']).original_build_info.pipeline).to eq job.pipeline end end + end - after do - Grape::Endpoint.before_each nil - end + context 'with a scoped name' do + let(:package_name) { "@#{group.path}/my_package_name" } - it 'creates the package metadata' do - upload_package_with_token(package_name, params) + it_behaves_like 'handling upload with different authentications' + end - expect(response).to have_gitlab_http_status(:ok) - expect(project.reload.packages.find(json_response['id']).original_build_info.pipeline).to eq job.pipeline - end + context 'with any scoped name' do + let(:package_name) { "@any_scope/my_package_name" } + + it_behaves_like 'handling upload with different authentications' + end + + context 'with an unscoped name' do + let(:package_name) { "my_unscoped_package_name" } + + it_behaves_like 'handling upload with different authentications' end end diff --git a/spec/requests/api/oauth_tokens_spec.rb b/spec/requests/api/oauth_tokens_spec.rb index 52c7408545f..edadfbc3d0c 100644 --- a/spec/requests/api/oauth_tokens_spec.rb +++ b/spec/requests/api/oauth_tokens_spec.rb @@ -27,13 +27,13 @@ RSpec.describe 'OAuth tokens' do context 'when user does not have 2FA enabled' do context 'when no client credentials provided' do - it 'does not create an access token' do + it 'creates an access token' do user = create(:user) request_oauth_token(user) - expect(response).to have_gitlab_http_status(:unauthorized) - expect(json_response['access_token']).to be_nil + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['access_token']).to be_present end end @@ -51,6 +51,8 @@ RSpec.describe 'OAuth tokens' do context 'with invalid credentials' do it 'does not create an access token' do + pending 'Enable this example after https://github.com/doorkeeper-gem/doorkeeper/pull/1488 is merged and released' + user = create(:user) request_oauth_token(user, basic_auth_header(client.uid, 'invalid secret')) diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml index 181fcafd577..6c9a845b217 100644 --- a/spec/requests/api/project_attributes.yml +++ b/spec/requests/api/project_attributes.yml @@ -12,7 +12,6 @@ itself: # project - import_source - import_type - import_url - - issues_template - jobs_cache_index - last_repository_check_at - last_repository_check_failed @@ -24,7 +23,6 @@ itself: # project - merge_requests_author_approval - merge_requests_disable_committers_approval - merge_requests_rebase_enabled - - merge_requests_template - mirror_last_successful_update_at - mirror_last_update_at - mirror_overwrites_diverged_branches @@ -56,6 +54,7 @@ itself: # project - can_create_merge_request_in - compliance_frameworks - container_expiration_policy + - container_registry_image_prefix - default_branch - empty_repo - forks_count @@ -117,6 +116,7 @@ project_feature: - project_id - requirements_access_level - security_and_compliance_access_level + - container_registry_access_level - updated_at computed_attributes: - issues_enabled @@ -139,6 +139,7 @@ project_setting: - show_default_award_emojis - squash_option - updated_at + - cve_id_request_enabled build_service_desk_setting: # service_desk_setting unexposed_attributes: diff --git a/spec/requests/api/project_packages_spec.rb b/spec/requests/api/project_packages_spec.rb index 1f3887cab8a..97414b3b18a 100644 --- a/spec/requests/api/project_packages_spec.rb +++ b/spec/requests/api/project_packages_spec.rb @@ -257,6 +257,10 @@ RSpec.describe API::ProjectPackages do context 'project is private' do let(:project) { create(:project, :private) } + before do + expect(::Packages::Maven::Metadata::SyncWorker).not_to receive(:perform_async) + end + it 'returns 404 for non authenticated user' do delete api(package_url) @@ -301,6 +305,19 @@ RSpec.describe API::ProjectPackages do expect(response).to have_gitlab_http_status(:no_content) end end + + context 'with a maven package' do + let_it_be(:package1) { create(:maven_package, project: project) } + + it 'enqueues a sync worker job' do + project.add_maintainer(user) + + expect(::Packages::Maven::Metadata::SyncWorker) + .to receive(:perform_async).with(user.id, project.id, package1.name) + + delete api(package_url, user) + end + end end end end diff --git a/spec/requests/api/project_repository_storage_moves_spec.rb b/spec/requests/api/project_repository_storage_moves_spec.rb index 5e200312d1f..b40645ba2de 100644 --- a/spec/requests/api/project_repository_storage_moves_spec.rb +++ b/spec/requests/api/project_repository_storage_moves_spec.rb @@ -7,6 +7,6 @@ RSpec.describe API::ProjectRepositoryStorageMoves do let_it_be(:container) { create(:project, :repository).tap { |project| project.track_project_repository } } let_it_be(:storage_move) { create(:project_repository_storage_move, :scheduled, container: container) } let(:repository_storage_move_factory) { :project_repository_storage_move } - let(:bulk_worker_klass) { ProjectScheduleBulkRepositoryShardMovesWorker } + let(:bulk_worker_klass) { Projects::ScheduleBulkRepositoryShardMovesWorker } end end diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index ad36777184a..d2a33e32b30 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -1478,6 +1478,120 @@ RSpec.describe API::Projects do end end + describe "GET /projects/:id/groups" do + let_it_be(:root_group) { create(:group, :public, name: 'root group') } + let_it_be(:project_group) { create(:group, :public, parent: root_group, name: 'project group') } + let_it_be(:shared_group_with_dev_access) { create(:group, :private, parent: root_group, name: 'shared group') } + let_it_be(:shared_group_with_reporter_access) { create(:group, :private) } + let_it_be(:private_project) { create(:project, :private, group: project_group) } + let_it_be(:public_project) { create(:project, :public, group: project_group) } + + before_all do + create(:project_group_link, :developer, group: shared_group_with_dev_access, project: private_project) + create(:project_group_link, :reporter, group: shared_group_with_reporter_access, project: private_project) + end + + shared_examples_for 'successful groups response' do + it 'returns an array of groups' do + request + + aggregate_failures do + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.map { |g| g['name'] }).to match_array(expected_groups.map(&:name)) + end + end + end + + context 'when unauthenticated' do + it 'does not return groups for private projects' do + get api("/projects/#{private_project.id}/groups") + + expect(response).to have_gitlab_http_status(:not_found) + end + + context 'for public projects' do + let(:request) { get api("/projects/#{public_project.id}/groups") } + + it_behaves_like 'successful groups response' do + let(:expected_groups) { [root_group, project_group] } + end + end + end + + context 'when authenticated as user' do + context 'when user does not have access to the project' do + it 'does not return groups' do + get api("/projects/#{private_project.id}/groups", user) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when user has access to the project' do + let(:request) { get api("/projects/#{private_project.id}/groups", user), params: params } + let(:params) { {} } + + before do + private_project.add_developer(user) + end + + it_behaves_like 'successful groups response' do + let(:expected_groups) { [root_group, project_group] } + end + + context 'when search by root group name' do + let(:params) { { search: 'root' } } + + it_behaves_like 'successful groups response' do + let(:expected_groups) { [root_group] } + end + end + + context 'with_shared option is on' do + let(:params) { { with_shared: true } } + + it_behaves_like 'successful groups response' do + let(:expected_groups) { [root_group, project_group, shared_group_with_dev_access, shared_group_with_reporter_access] } + end + + context 'when shared_min_access_level is set' do + let(:params) { super().merge(shared_min_access_level: Gitlab::Access::DEVELOPER) } + + it_behaves_like 'successful groups response' do + let(:expected_groups) { [root_group, project_group, shared_group_with_dev_access] } + end + end + + context 'when search by shared group name' do + let(:params) { super().merge(search: 'shared') } + + it_behaves_like 'successful groups response' do + let(:expected_groups) { [shared_group_with_dev_access] } + end + end + + context 'when skip_groups is set' do + let(:params) { super().merge(skip_groups: [shared_group_with_dev_access.id, root_group.id]) } + + it_behaves_like 'successful groups response' do + let(:expected_groups) { [shared_group_with_reporter_access, project_group] } + end + end + end + end + end + + context 'when authenticated as admin' do + let(:request) { get api("/projects/#{private_project.id}/groups", admin) } + + it_behaves_like 'successful groups response' do + let(:expected_groups) { [root_group, project_group] } + end + end + end + describe 'GET /projects/:id' do context 'when unauthenticated' do it 'does not return private projects' do @@ -1540,6 +1654,10 @@ RSpec.describe API::Projects do end context 'when authenticated as an admin' do + before do + stub_container_registry_config(enabled: true, host_port: 'registry.example.org:5000') + end + let(:project_attributes_file) { 'spec/requests/api/project_attributes.yml' } let(:project_attributes) { YAML.load_file(project_attributes_file) } @@ -1563,13 +1681,15 @@ RSpec.describe API::Projects do mirror requirements_enabled security_and_compliance_enabled + issues_template + merge_requests_template ] end keys end - it 'returns a project by id' do + it 'returns a project by id', :aggregate_failures do project project_member group = create(:group) @@ -1587,6 +1707,7 @@ RSpec.describe API::Projects do expect(json_response['ssh_url_to_repo']).to be_present expect(json_response['http_url_to_repo']).to be_present expect(json_response['web_url']).to be_present + expect(json_response['container_registry_image_prefix']).to eq("registry.example.org:5000/#{project.full_path}") expect(json_response['owner']).to be_a Hash expect(json_response['name']).to eq(project.name) expect(json_response['path']).to be_present @@ -1644,9 +1765,10 @@ RSpec.describe API::Projects do before do project project_member + stub_container_registry_config(enabled: true, host_port: 'registry.example.org:5000') end - it 'returns a project by id' do + it 'returns a project by id', :aggregate_failures do group = create(:group) link = create(:project_group_link, project: project, group: group) @@ -1662,6 +1784,7 @@ RSpec.describe API::Projects do expect(json_response['ssh_url_to_repo']).to be_present expect(json_response['http_url_to_repo']).to be_present expect(json_response['web_url']).to be_present + expect(json_response['container_registry_image_prefix']).to eq("registry.example.org:5000/#{project.full_path}") expect(json_response['owner']).to be_a Hash expect(json_response['name']).to eq(project.name) expect(json_response['path']).to be_present @@ -2818,7 +2941,7 @@ RSpec.describe API::Projects do Sidekiq::Testing.fake! do put(api("/projects/#{new_project.id}", user), params: { repository_storage: unknown_storage, issues_enabled: false }) end - end.not_to change(ProjectUpdateRepositoryStorageWorker.jobs, :size) + end.not_to change(Projects::UpdateRepositoryStorageWorker.jobs, :size) expect(response).to have_gitlab_http_status(:ok) expect(json_response['issues_enabled']).to eq(false) @@ -2845,7 +2968,7 @@ RSpec.describe API::Projects do Sidekiq::Testing.fake! do put(api("/projects/#{new_project.id}", admin), params: { repository_storage: 'test_second_storage' }) end - end.to change(ProjectUpdateRepositoryStorageWorker.jobs, :size).by(1) + end.to change(Projects::UpdateRepositoryStorageWorker.jobs, :size).by(1) expect(response).to have_gitlab_http_status(:ok) end diff --git a/spec/requests/api/protected_branches_spec.rb b/spec/requests/api/protected_branches_spec.rb index 8bcd493eb1f..6b1aa576167 100644 --- a/spec/requests/api/protected_branches_spec.rb +++ b/spec/requests/api/protected_branches_spec.rb @@ -68,6 +68,7 @@ RSpec.describe API::ProtectedBranches do expect(response).to have_gitlab_http_status(:ok) expect(json_response['name']).to eq(branch_name) + expect(json_response['allow_force_push']).to eq(false) expect(json_response['push_access_levels'][0]['access_level']).to eq(::Gitlab::Access::MAINTAINER) expect(json_response['merge_access_levels'][0]['access_level']).to eq(::Gitlab::Access::MAINTAINER) end @@ -132,6 +133,7 @@ RSpec.describe API::ProtectedBranches do expect(response).to have_gitlab_http_status(:created) expect(json_response['name']).to eq(branch_name) + expect(json_response['allow_force_push']).to eq(false) expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER) expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER) end @@ -141,6 +143,7 @@ RSpec.describe API::ProtectedBranches do expect(response).to have_gitlab_http_status(:created) expect(json_response['name']).to eq(branch_name) + expect(json_response['allow_force_push']).to eq(false) expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::DEVELOPER) expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER) end @@ -150,6 +153,7 @@ RSpec.describe API::ProtectedBranches do expect(response).to have_gitlab_http_status(:created) expect(json_response['name']).to eq(branch_name) + expect(json_response['allow_force_push']).to eq(false) expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER) expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::DEVELOPER) end @@ -159,6 +163,7 @@ RSpec.describe API::ProtectedBranches do expect(response).to have_gitlab_http_status(:created) expect(json_response['name']).to eq(branch_name) + expect(json_response['allow_force_push']).to eq(false) expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::DEVELOPER) expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::DEVELOPER) end @@ -168,6 +173,7 @@ RSpec.describe API::ProtectedBranches do expect(response).to have_gitlab_http_status(:created) expect(json_response['name']).to eq(branch_name) + expect(json_response['allow_force_push']).to eq(false) expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::NO_ACCESS) expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER) end @@ -177,6 +183,7 @@ RSpec.describe API::ProtectedBranches do expect(response).to have_gitlab_http_status(:created) expect(json_response['name']).to eq(branch_name) + expect(json_response['allow_force_push']).to eq(false) expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER) expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::NO_ACCESS) end @@ -186,10 +193,21 @@ RSpec.describe API::ProtectedBranches do expect(response).to have_gitlab_http_status(:created) expect(json_response['name']).to eq(branch_name) + expect(json_response['allow_force_push']).to eq(false) expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::NO_ACCESS) expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::NO_ACCESS) end + it 'protects a single branch and allows force pushes' do + post post_endpoint, params: { name: branch_name, allow_force_push: true } + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['name']).to eq(branch_name) + expect(json_response['allow_force_push']).to eq(true) + expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER) + expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER) + end + it 'returns a 409 error if the same branch is protected twice' do post post_endpoint, params: { name: protected_name } diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb index ace73e49c7c..31f0d7cec2a 100644 --- a/spec/requests/api/repositories_spec.rb +++ b/spec/requests/api/repositories_spec.rb @@ -650,6 +650,40 @@ RSpec.describe API::Repositories do expect(response).to have_gitlab_http_status(:ok) end + it 'supports leaving out the from and to attribute' do + spy = instance_spy(Repositories::ChangelogService) + + allow(Repositories::ChangelogService) + .to receive(:new) + .with( + project, + user, + version: '1.0.0', + date: DateTime.new(2020, 1, 1), + branch: 'kittens', + trailer: 'Foo', + file: 'FOO.md', + message: 'Commit message' + ) + .and_return(spy) + + expect(spy).to receive(:execute) + + post( + api("/projects/#{project.id}/repository/changelog", user), + params: { + version: '1.0.0', + date: '2020-01-01', + branch: 'kittens', + trailer: 'Foo', + file: 'FOO.md', + message: 'Commit message' + } + ) + + expect(response).to have_gitlab_http_status(:ok) + end + it 'produces an error when generating the changelog fails' do spy = instance_spy(Repositories::ChangelogService) diff --git a/spec/requests/api/resource_access_tokens_spec.rb b/spec/requests/api/resource_access_tokens_spec.rb index 9fd7eb2177d..79549bfc5e0 100644 --- a/spec/requests/api/resource_access_tokens_spec.rb +++ b/spec/requests/api/resource_access_tokens_spec.rb @@ -30,6 +30,18 @@ RSpec.describe API::ResourceAccessTokens do expect(token_ids).to match_array(access_tokens.pluck(:id)) end + it "exposes the correct token information", :aggregate_failures do + get_tokens + + token = access_tokens.last + api_get_token = json_response.last + + expect(api_get_token["name"]).to eq(token.name) + expect(api_get_token["scopes"]).to eq(token.scopes) + expect(api_get_token["expires_at"]).to eq(token.expires_at.to_date.iso8601) + expect(api_get_token).not_to have_key('token') + end + context "when using a project access token to GET other project access tokens" do let_it_be(:token) { access_tokens.first } @@ -182,13 +194,13 @@ RSpec.describe API::ResourceAccessTokens do end describe "POST projects/:id/access_tokens" do - let_it_be(:params) { { name: "test", scopes: ["api"], expires_at: Date.today + 1.month } } + let(:params) { { name: "test", scopes: ["api"], expires_at: expires_at } } + let(:expires_at) { 1.month.from_now } subject(:create_token) { post api("/projects/#{project_id}/access_tokens", user), params: params } context "when the user has maintainer permissions" do let_it_be(:project_id) { project.id } - let_it_be(:expires_at) { 1.month.from_now } before do project.add_maintainer(user) @@ -203,11 +215,12 @@ RSpec.describe API::ResourceAccessTokens do expect(json_response["name"]).to eq("test") expect(json_response["scopes"]).to eq(["api"]) expect(json_response["expires_at"]).to eq(expires_at.to_date.iso8601) + expect(json_response["token"]).to be_present end end context "when 'expires_at' is not set" do - let_it_be(:params) { { name: "test", scopes: ["api"] } } + let(:expires_at) { nil } it "creates a project access token with the params", :aggregate_failures do create_token diff --git a/spec/requests/api/rubygem_packages_spec.rb b/spec/requests/api/rubygem_packages_spec.rb index 5dd68bf9b10..d6ad8186063 100644 --- a/spec/requests/api/rubygem_packages_spec.rb +++ b/spec/requests/api/rubygem_packages_spec.rb @@ -3,9 +3,11 @@ require 'spec_helper' RSpec.describe API::RubygemPackages do + include PackagesManagerApiSpecHelpers + include WorkhorseHelpers using RSpec::Parameterized::TableSyntax - let_it_be(:project) { create(:project) } + let_it_be_with_reload(:project) { create(:project) } let_it_be(:personal_access_token) { create(:personal_access_token) } let_it_be(:user) { personal_access_token.user } let_it_be(:job) { create(:ci_build, :running, user: user) } @@ -13,6 +15,14 @@ RSpec.describe API::RubygemPackages do let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) } let_it_be(:headers) { {} } + let(:tokens) do + { + personal_access_token: personal_access_token.token, + deploy_token: deploy_token.token, + job_token: job.token + } + end + shared_examples 'when feature flag is disabled' do let(:headers) do { 'HTTP_AUTHORIZATION' => personal_access_token.token } @@ -34,7 +44,7 @@ RSpec.describe API::RubygemPackages do end shared_examples 'without authentication' do - it_behaves_like 'returning response status', :unauthorized + it_behaves_like 'returning response status', :not_found end shared_examples 'with authentication' do @@ -42,14 +52,6 @@ RSpec.describe API::RubygemPackages do { 'HTTP_AUTHORIZATION' => token } end - let(:tokens) do - { - personal_access_token: personal_access_token.token, - deploy_token: deploy_token.token, - job_token: job.token - } - end - where(:user_role, :token_type, :valid_token, :status) do :guest | :personal_access_token | true | :not_found :guest | :personal_access_token | false | :unauthorized @@ -106,34 +108,290 @@ RSpec.describe API::RubygemPackages do end describe 'GET /api/v4/projects/:project_id/packages/rubygems/gems/:file_name' do - let(:url) { api("/projects/#{project.id}/packages/rubygems/gems/my_gem-1.0.0.gem") } + let_it_be(:package_name) { 'package' } + let_it_be(:version) { '0.0.1' } + let_it_be(:package) { create(:rubygems_package, project: project, name: package_name, version: version) } + let_it_be(:file_name) { "#{package_name}-#{version}.gem" } + + let(:url) { api("/projects/#{project.id}/packages/rubygems/gems/#{file_name}") } subject { get(url, headers: headers) } - it_behaves_like 'an unimplemented route' + context 'with valid project' do + where(:visibility, :user_role, :member, :token_type, :valid_token, :shared_examples_name, :expected_status) do + :public | :developer | true | :personal_access_token | true | 'Rubygems gem download' | :success + :public | :guest | true | :personal_access_token | true | 'Rubygems gem download' | :success + :public | :developer | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :guest | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :developer | false | :personal_access_token | true | 'Rubygems gem download' | :success + :public | :guest | false | :personal_access_token | true | 'Rubygems gem download' | :success + :public | :developer | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :guest | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :anonymous | false | :personal_access_token | true | 'Rubygems gem download' | :success + :private | :developer | true | :personal_access_token | true | 'Rubygems gem download' | :success + :private | :guest | true | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden + :private | :developer | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :guest | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :developer | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found + :private | :guest | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found + :private | :developer | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :guest | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :anonymous | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found + :public | :developer | true | :job_token | true | 'Rubygems gem download' | :success + :public | :guest | true | :job_token | true | 'Rubygems gem download' | :success + :public | :developer | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :guest | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :developer | false | :job_token | true | 'Rubygems gem download' | :success + :public | :guest | false | :job_token | true | 'Rubygems gem download' | :success + :public | :developer | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :guest | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :developer | true | :job_token | true | 'Rubygems gem download' | :success + :private | :guest | true | :job_token | true | 'rejects rubygems packages access' | :forbidden + :private | :developer | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :guest | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :developer | false | :job_token | true | 'rejects rubygems packages access' | :not_found + :private | :guest | false | :job_token | true | 'rejects rubygems packages access' | :not_found + :private | :developer | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :guest | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :developer | true | :deploy_token | true | 'Rubygems gem download' | :success + :public | :developer | true | :deploy_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :developer | true | :deploy_token | true | 'Rubygems gem download' | :success + :private | :developer | true | :deploy_token | false | 'rejects rubygems packages access' | :unauthorized + end + + with_them do + let(:token) { valid_token ? tokens[token_type] : 'invalid-token123' } + let(:headers) { user_role == :anonymous ? {} : { 'HTTP_AUTHORIZATION' => token } } + + before do + project.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value(visibility.to_s)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end end describe 'POST /api/v4/projects/:project_id/packages/rubygems/api/v1/gems/authorize' do + include_context 'workhorse headers' + let(:url) { api("/projects/#{project.id}/packages/rubygems/api/v1/gems/authorize") } + let(:headers) { {} } subject { post(url, headers: headers) } - it_behaves_like 'an unimplemented route' + context 'with valid project' do + where(:visibility, :user_role, :member, :token_type, :valid_token, :shared_examples_name, :expected_status) do + :public | :developer | true | :personal_access_token | true | 'process rubygems workhorse authorization' | :success + :public | :guest | true | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden + :public | :developer | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :guest | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :developer | false | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden + :public | :guest | false | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden + :public | :developer | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :guest | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :anonymous | false | :personal_access_token | true | 'rejects rubygems packages access' | :unauthorized + :private | :developer | true | :personal_access_token | true | 'process rubygems workhorse authorization' | :success + :private | :guest | true | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden + :private | :developer | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :guest | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :developer | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found + :private | :guest | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found + :private | :developer | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :guest | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :anonymous | false | :personal_access_token | true | 'rejects rubygems packages access' | :unauthorized + :public | :developer | true | :job_token | true | 'process rubygems workhorse authorization' | :success + :public | :guest | true | :job_token | true | 'rejects rubygems packages access' | :forbidden + :public | :developer | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :guest | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :developer | false | :job_token | true | 'rejects rubygems packages access' | :forbidden + :public | :guest | false | :job_token | true | 'rejects rubygems packages access' | :forbidden + :public | :developer | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :guest | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :developer | true | :job_token | true | 'process rubygems workhorse authorization' | :success + :private | :guest | true | :job_token | true | 'rejects rubygems packages access' | :forbidden + :private | :developer | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :guest | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :developer | false | :job_token | true | 'rejects rubygems packages access' | :not_found + :private | :guest | false | :job_token | true | 'rejects rubygems packages access' | :not_found + :private | :developer | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :guest | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :developer | true | :deploy_token | true | 'process rubygems workhorse authorization' | :success + :public | :developer | true | :deploy_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :developer | true | :deploy_token | true | 'process rubygems workhorse authorization' | :success + :private | :developer | true | :deploy_token | false | 'rejects rubygems packages access' | :unauthorized + end + + with_them do + let(:token) { valid_token ? tokens[token_type] : 'invalid-token123' } + let(:user_headers) { user_role == :anonymous ? {} : { 'HTTP_AUTHORIZATION' => token } } + let(:headers) { user_headers.merge(workhorse_headers) } + + before do + project.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value(visibility.to_s)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end end describe 'POST /api/v4/projects/:project_id/packages/rubygems/api/v1/gems' do - let(:url) { api("/projects/#{project.id}/packages/rubygems/api/v1/gems") } + include_context 'workhorse headers' + + let(:url) { "/projects/#{project.id}/packages/rubygems/api/v1/gems" } + + let_it_be(:file_name) { 'package.gem' } + let(:headers) { {} } + let(:params) { { file: temp_file(file_name) } } + let(:file_key) { :file } + let(:send_rewritten_field) { true } + + subject do + workhorse_finalize( + api(url), + method: :post, + file_key: file_key, + params: params, + headers: headers, + send_rewritten_field: send_rewritten_field + ) + end - subject { post(url, headers: headers) } + context 'with valid project' do + where(:visibility, :user_role, :member, :token_type, :valid_token, :shared_examples_name, :expected_status) do + :public | :developer | true | :personal_access_token | true | 'process rubygems upload' | :created + :public | :guest | true | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden + :public | :developer | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :guest | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :developer | false | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden + :public | :guest | false | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden + :public | :developer | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :guest | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :anonymous | false | :personal_access_token | true | 'rejects rubygems packages access' | :unauthorized + :private | :developer | true | :personal_access_token | true | 'process rubygems upload' | :created + :private | :guest | true | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden + :private | :developer | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :guest | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :developer | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found + :private | :guest | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found + :private | :developer | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :guest | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :anonymous | false | :personal_access_token | true | 'rejects rubygems packages access' | :unauthorized + :public | :developer | true | :job_token | true | 'process rubygems upload' | :created + :public | :guest | true | :job_token | true | 'rejects rubygems packages access' | :forbidden + :public | :developer | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :guest | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :developer | false | :job_token | true | 'rejects rubygems packages access' | :forbidden + :public | :guest | false | :job_token | true | 'rejects rubygems packages access' | :forbidden + :public | :developer | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :guest | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :developer | true | :job_token | true | 'process rubygems upload' | :created + :private | :guest | true | :job_token | true | 'rejects rubygems packages access' | :forbidden + :private | :developer | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :guest | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :developer | false | :job_token | true | 'rejects rubygems packages access' | :not_found + :private | :guest | false | :job_token | true | 'rejects rubygems packages access' | :not_found + :private | :developer | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :guest | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :developer | true | :deploy_token | true | 'process rubygems upload' | :created + :public | :developer | true | :deploy_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :developer | true | :deploy_token | true | 'process rubygems upload' | :created + :private | :developer | true | :deploy_token | false | 'rejects rubygems packages access' | :unauthorized + end - it_behaves_like 'an unimplemented route' + with_them do + let(:token) { valid_token ? tokens[token_type] : 'invalid-token123' } + let(:user_headers) { user_role == :anonymous ? {} : { 'HTTP_AUTHORIZATION' => token } } + let(:headers) { user_headers.merge(workhorse_headers) } + + before do + project.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value(visibility.to_s)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + + context 'failed package file save' do + let(:user_headers) { { 'HTTP_AUTHORIZATION' => personal_access_token.token } } + let(:headers) { user_headers.merge(workhorse_headers) } + + before do + project.add_developer(user) + end + + it 'does not create package record', :aggregate_failures do + allow(Packages::CreatePackageFileService).to receive(:new).and_raise(StandardError) + + expect { subject } + .to change { project.packages.count }.by(0) + .and change { Packages::PackageFile.count }.by(0) + expect(response).to have_gitlab_http_status(:error) + end + end + end end describe 'GET /api/v4/projects/:project_id/packages/rubygems/api/v1/dependencies' do + let_it_be(:package) { create(:rubygems_package, project: project) } + let(:url) { api("/projects/#{project.id}/packages/rubygems/api/v1/dependencies") } - subject { get(url, headers: headers) } + subject { get(url, headers: headers, params: params) } + + context 'with valid project' do + where(:visibility, :user_role, :member, :token_type, :valid_token, :shared_examples_name, :expected_status) do + :public | :developer | true | :personal_access_token | true | 'dependency endpoint success' | :success + :public | :guest | true | :personal_access_token | true | 'dependency endpoint success' | :success + :public | :developer | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :guest | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :developer | false | :personal_access_token | true | 'dependency endpoint success' | :success + :public | :guest | false | :personal_access_token | true | 'dependency endpoint success' | :success + :public | :developer | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :guest | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :anonymous | false | :personal_access_token | true | 'dependency endpoint success' | :success + :private | :developer | true | :personal_access_token | true | 'dependency endpoint success' | :success + :private | :guest | true | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden + :private | :developer | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :guest | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :developer | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found + :private | :guest | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found + :private | :developer | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :guest | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :anonymous | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found + :public | :developer | true | :job_token | true | 'dependency endpoint success' | :success + :public | :guest | true | :job_token | true | 'dependency endpoint success' | :success + :public | :developer | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :guest | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :developer | false | :job_token | true | 'dependency endpoint success' | :success + :public | :guest | false | :job_token | true | 'dependency endpoint success' | :success + :public | :developer | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :guest | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :developer | true | :job_token | true | 'dependency endpoint success' | :success + :private | :guest | true | :job_token | true | 'rejects rubygems packages access' | :forbidden + :private | :developer | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :guest | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :developer | false | :job_token | true | 'rejects rubygems packages access' | :not_found + :private | :guest | false | :job_token | true | 'rejects rubygems packages access' | :not_found + :private | :developer | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :guest | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized + :public | :developer | true | :deploy_token | true | 'dependency endpoint success' | :success + :public | :developer | true | :deploy_token | false | 'rejects rubygems packages access' | :unauthorized + :private | :developer | true | :deploy_token | true | 'dependency endpoint success' | :success + :private | :developer | true | :deploy_token | false | 'rejects rubygems packages access' | :unauthorized + end - it_behaves_like 'an unimplemented route' + with_them do + let(:token) { valid_token ? tokens[token_type] : 'invalid-token123' } + let(:headers) { user_role == :anonymous ? {} : { 'HTTP_AUTHORIZATION' => token } } + let(:params) { {} } + + before do + project.update!(visibility: visibility.to_s) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end end end diff --git a/spec/requests/api/snippet_repository_storage_moves_spec.rb b/spec/requests/api/snippet_repository_storage_moves_spec.rb index edb92569823..40d01500ac1 100644 --- a/spec/requests/api/snippet_repository_storage_moves_spec.rb +++ b/spec/requests/api/snippet_repository_storage_moves_spec.rb @@ -7,6 +7,6 @@ RSpec.describe API::SnippetRepositoryStorageMoves do let_it_be(:container) { create(:snippet, :repository).tap { |snippet| snippet.create_repository } } let_it_be(:storage_move) { create(:snippet_repository_storage_move, :scheduled, container: container) } let(:repository_storage_move_factory) { :snippet_repository_storage_move } - let(:bulk_worker_klass) { SnippetScheduleBulkRepositoryShardMovesWorker } + let(:bulk_worker_klass) { Snippets::ScheduleBulkRepositoryShardMovesWorker } end end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index d70a8bd692d..2a7689eaddf 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -320,6 +320,18 @@ RSpec.describe API::Users do expect(json_response).to all(include('state' => /(blocked|ldap_blocked)/)) end + it "returns an array of external users" do + create(:user) + external_user = create(:user, external: true) + + get api("/users?external=true", user) + + expect(response).to match_response_schema('public_api/v4/user/basics') + expect(response).to include_pagination_headers + expect(json_response.size).to eq(1) + expect(json_response[0]['id']).to eq(external_user.id) + end + it "returns one user" do get api("/users?username=#{omniauth_user.username}", user) @@ -940,6 +952,18 @@ RSpec.describe API::Users do expect(new_user.private_profile?).to eq(true) end + it "creates user with view_diffs_file_by_file" do + post api('/users', admin), params: attributes_for(:user, view_diffs_file_by_file: true) + + expect(response).to have_gitlab_http_status(:created) + + user_id = json_response['id'] + new_user = User.find(user_id) + + expect(new_user).not_to eq(nil) + expect(new_user.user_preference.view_diffs_file_by_file?).to eq(true) + end + it "does not create user with invalid email" do post api('/users', admin), params: { @@ -1254,6 +1278,13 @@ RSpec.describe API::Users do expect(user.reload.private_profile).to eq(true) end + it "updates viewing diffs file by file" do + put api("/users/#{user.id}", admin), params: { view_diffs_file_by_file: true } + + expect(response).to have_gitlab_http_status(:ok) + expect(user.reload.user_preference.view_diffs_file_by_file?).to eq(true) + end + it "updates private profile to false when nil is given" do user.update!(private_profile: true) @@ -3044,18 +3075,6 @@ RSpec.describe API::Users do expect(response).to have_gitlab_http_status(:bad_request) end - - context 'when the clear_status_with_quick_options feature flag is disabled' do - before do - stub_feature_flags(clear_status_with_quick_options: false) - end - - it 'does not persist clear_status_at' do - put api('/user/status', user), params: { emoji: 'smirk', message: 'hello world', clear_status_after: '3_hours' } - - expect(user.status.reload.clear_status_at).to be_nil - end - end end end diff --git a/spec/requests/api/v3/github_spec.rb b/spec/requests/api/v3/github_spec.rb index e7d9ba99743..197c6cbb0eb 100644 --- a/spec/requests/api/v3/github_spec.rb +++ b/spec/requests/api/v3/github_spec.rb @@ -149,6 +149,8 @@ RSpec.describe API::V3::Github do end describe 'GET events' do + include ProjectForksHelper + let(:group) { create(:group) } let(:project) { create(:project, :empty_repo, path: 'project.with.dot', group: group) } let(:events_path) { "/repos/#{group.path}/#{project.path}/events" } @@ -174,6 +176,17 @@ RSpec.describe API::V3::Github do end end + it 'avoids N+1 queries' do + create(:merge_request, source_project: project) + source_project = fork_project(project, nil, repository: true) + + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { jira_get v3_api(events_path, user) }.count + + create_list(:merge_request, 2, :unique_branches, source_project: source_project, target_project: project) + + expect { jira_get v3_api(events_path, user) }.not_to exceed_all_query_limit(control_count) + end + context 'if there are more merge requests' do let!(:merge_request) { create(:merge_request, id: 10000, source_project: project, target_project: project, author: user) } let!(:merge_request2) { create(:merge_request, id: 10001, source_project: project, source_branch: generate(:branch), target_project: project, author: user) } diff --git a/spec/requests/api/wikis_spec.rb b/spec/requests/api/wikis_spec.rb index f271f8aa853..d35aab40ca9 100644 --- a/spec/requests/api/wikis_spec.rb +++ b/spec/requests/api/wikis_spec.rb @@ -14,6 +14,7 @@ require 'spec_helper' RSpec.describe API::Wikis do include WorkhorseHelpers + include AfterNextHelpers let(:user) { create(:user) } let(:group) { create(:group).tap { |g| g.add_owner(user) } } @@ -578,6 +579,20 @@ RSpec.describe API::Wikis do include_examples 'wiki API 404 Wiki Page Not Found' end end + + context 'when there is an error deleting the page' do + it 'returns 422' do + project.add_maintainer(user) + + allow_next(WikiPages::DestroyService, current_user: user, container: project) + .to receive(:execute).and_return(ServiceResponse.error(message: 'foo')) + + delete(api(url, user)) + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response['message']).to eq 'foo' + end + end end context 'when wiki belongs to a group project' do diff --git a/spec/requests/ide_controller_spec.rb b/spec/requests/ide_controller_spec.rb index 805c1f1d82b..4f127e07b6b 100644 --- a/spec/requests/ide_controller_spec.rb +++ b/spec/requests/ide_controller_spec.rb @@ -3,7 +3,11 @@ require 'spec_helper' RSpec.describe IdeController do - let(:user) { create(:user) } + let_it_be(:project) { create(:project, :public) } + let_it_be(:creator) { project.creator } + let_it_be(:other_user) { create(:user) } + + let(:user) { creator } before do sign_in(user) @@ -14,4 +18,172 @@ RSpec.describe IdeController do get ide_url end + + describe '#index', :aggregate_failures do + subject { get route } + + shared_examples 'user cannot push code' do + include ProjectForksHelper + + let(:user) { other_user } + + context 'when user does not have fork' do + it 'does not instantiate forked_project instance var and return 200' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(assigns(:project)).to eq project + expect(assigns(:forked_project)).to be_nil + end + end + + context 'when user has have fork' do + let!(:fork) { fork_project(project, user, repository: true) } + + it 'instantiates forked_project instance var and return 200' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(assigns(:project)).to eq project + expect(assigns(:forked_project)).to eq fork + end + end + end + + context '/-/ide' do + let(:route) { '/-/ide' } + + it 'does not instantiate any instance var and return 200' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(assigns(:project)).to be_nil + expect(assigns(:branch)).to be_nil + expect(assigns(:path)).to be_nil + expect(assigns(:merge_request)).to be_nil + expect(assigns(:forked_project)).to be_nil + end + end + + context '/-/ide/project' do + let(:route) { '/-/ide/project' } + + it 'does not instantiate any instance var and return 200' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(assigns(:project)).to be_nil + expect(assigns(:branch)).to be_nil + expect(assigns(:path)).to be_nil + expect(assigns(:merge_request)).to be_nil + expect(assigns(:forked_project)).to be_nil + end + end + + context '/-/ide/project/:project' do + let(:route) { "/-/ide/project/#{project.full_path}" } + + it 'instantiates project instance var and return 200' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(assigns(:project)).to eq project + expect(assigns(:branch)).to be_nil + expect(assigns(:path)).to be_nil + expect(assigns(:merge_request)).to be_nil + expect(assigns(:forked_project)).to be_nil + end + + it_behaves_like 'user cannot push code' + + %w(edit blob tree).each do |action| + context "/-/ide/project/:project/#{action}" do + let(:route) { "/-/ide/project/#{project.full_path}/#{action}" } + + it 'instantiates project instance var and return 200' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(assigns(:project)).to eq project + expect(assigns(:branch)).to be_nil + expect(assigns(:path)).to be_nil + expect(assigns(:merge_request)).to be_nil + expect(assigns(:forked_project)).to be_nil + end + + it_behaves_like 'user cannot push code' + + context "/-/ide/project/:project/#{action}/:branch" do + let(:route) { "/-/ide/project/#{project.full_path}/#{action}/master" } + + it 'instantiates project and branch instance vars and return 200' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(assigns(:project)).to eq project + expect(assigns(:branch)).to eq 'master' + expect(assigns(:path)).to be_nil + expect(assigns(:merge_request)).to be_nil + expect(assigns(:forked_project)).to be_nil + end + + it_behaves_like 'user cannot push code' + + context "/-/ide/project/:project/#{action}/:branch/-" do + let(:route) { "/-/ide/project/#{project.full_path}/#{action}/branch/slash/-" } + + it 'instantiates project and branch instance vars and return 200' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(assigns(:project)).to eq project + expect(assigns(:branch)).to eq 'branch/slash' + expect(assigns(:path)).to be_nil + expect(assigns(:merge_request)).to be_nil + expect(assigns(:forked_project)).to be_nil + end + + it_behaves_like 'user cannot push code' + + context "/-/ide/project/:project/#{action}/:branch/-/:path" do + let(:route) { "/-/ide/project/#{project.full_path}/#{action}/master/-/foo/.bar" } + + it 'instantiates project, branch, and path instance vars and return 200' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(assigns(:project)).to eq project + expect(assigns(:branch)).to eq 'master' + expect(assigns(:path)).to eq 'foo/.bar' + expect(assigns(:merge_request)).to be_nil + expect(assigns(:forked_project)).to be_nil + end + + it_behaves_like 'user cannot push code' + end + end + end + end + end + + context '/-/ide/project/:project/merge_requests/:merge_request_id' do + let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + + let(:route) { "/-/ide/project/#{project.full_path}/merge_requests/#{merge_request.id}" } + + it 'instantiates project and merge_request instance vars and return 200' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(assigns(:project)).to eq project + expect(assigns(:branch)).to be_nil + expect(assigns(:path)).to be_nil + expect(assigns(:merge_request)).to eq merge_request.id.to_s + expect(assigns(:forked_project)).to be_nil + end + + it_behaves_like 'user cannot push code' + end + end + end end diff --git a/spec/requests/projects/merge_requests/content_spec.rb b/spec/requests/projects/merge_requests/content_spec.rb new file mode 100644 index 00000000000..7e5ec6f64c4 --- /dev/null +++ b/spec/requests/projects/merge_requests/content_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'merge request content spec' do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + let_it_be(:merge_request) { create(:merge_request, :with_head_pipeline, target_project: project, source_project: project) } + let_it_be(:ci_build) { create(:ci_build, :artifacts, pipeline: merge_request.head_pipeline) } + + before do + sign_in(user) + project.add_maintainer(user) + end + + shared_examples 'cached widget request' do + it 'avoids N+1 queries when multiple job artifacts are present' do + control = ActiveRecord::QueryRecorder.new(skip_cached: false) do + get cached_widget_project_json_merge_request_path(project, merge_request, format: :json) + end + + create_list(:ci_build, 10, :artifacts, pipeline: merge_request.head_pipeline) + + expect do + get cached_widget_project_json_merge_request_path(project, merge_request, format: :json) + end.not_to exceed_query_limit(control) + end + end + + describe 'GET cached_widget' do + it_behaves_like 'cached widget request' + + context 'with non_public_artifacts disabled' do + before do + stub_feature_flags(non_public_artifacts: false) + end + + it_behaves_like 'cached widget request' + end + end +end diff --git a/spec/requests/projects/noteable_notes_spec.rb b/spec/requests/projects/noteable_notes_spec.rb index 5ae2aadaa84..2bf1ffb2edc 100644 --- a/spec/requests/projects/noteable_notes_spec.rb +++ b/spec/requests/projects/noteable_notes_spec.rb @@ -18,7 +18,9 @@ RSpec.describe 'Project noteable notes' do login_as(user) end - it 'does not set a Gitlab::EtagCaching ETag' do + it 'does not set a Gitlab::EtagCaching ETag if there is a note' do + create(:note_on_merge_request, noteable: merge_request, project: merge_request.project) + get notes_path expect(response).to have_gitlab_http_status(:ok) @@ -27,5 +29,12 @@ RSpec.describe 'Project noteable notes' do # interfere with notes pagination expect(response_etag).not_to eq(stored_etag) end + + it 'sets a Gitlab::EtagCaching ETag if there is no note' do + get notes_path + + expect(response).to have_gitlab_http_status(:ok) + expect(response_etag).to eq(stored_etag) + end end end diff --git a/spec/rubocop/code_reuse_helpers_spec.rb b/spec/rubocop/code_reuse_helpers_spec.rb index 4c3dd8f8167..9337df368e3 100644 --- a/spec/rubocop/code_reuse_helpers_spec.rb +++ b/spec/rubocop/code_reuse_helpers_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require 'parser/current' require_relative '../../rubocop/code_reuse_helpers' diff --git a/spec/rubocop/cop/active_record_association_reload_spec.rb b/spec/rubocop/cop/active_record_association_reload_spec.rb index f28c4e60f3c..1c0518815ee 100644 --- a/spec/rubocop/cop/active_record_association_reload_spec.rb +++ b/spec/rubocop/cop/active_record_association_reload_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../rubocop/cop/active_record_association_reload' RSpec.describe RuboCop::Cop::ActiveRecordAssociationReload do diff --git a/spec/rubocop/cop/api/base_spec.rb b/spec/rubocop/cop/api/base_spec.rb index ec646b9991b..547d3f53a08 100644 --- a/spec/rubocop/cop/api/base_spec.rb +++ b/spec/rubocop/cop/api/base_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/api/base' RSpec.describe RuboCop::Cop::API::Base do diff --git a/spec/rubocop/cop/api/grape_array_missing_coerce_spec.rb b/spec/rubocop/cop/api/grape_array_missing_coerce_spec.rb index b50866b54b3..01f1fc71f9a 100644 --- a/spec/rubocop/cop/api/grape_array_missing_coerce_spec.rb +++ b/spec/rubocop/cop/api/grape_array_missing_coerce_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/api/grape_array_missing_coerce' RSpec.describe RuboCop::Cop::API::GrapeArrayMissingCoerce do diff --git a/spec/rubocop/cop/avoid_becomes_spec.rb b/spec/rubocop/cop/avoid_becomes_spec.rb index 401c694f373..3ab1544b00d 100644 --- a/spec/rubocop/cop/avoid_becomes_spec.rb +++ b/spec/rubocop/cop/avoid_becomes_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../rubocop/cop/avoid_becomes' RSpec.describe RuboCop::Cop::AvoidBecomes do diff --git a/spec/rubocop/cop/avoid_break_from_strong_memoize_spec.rb b/spec/rubocop/cop/avoid_break_from_strong_memoize_spec.rb index ac59d36db3f..cc851045c3c 100644 --- a/spec/rubocop/cop/avoid_break_from_strong_memoize_spec.rb +++ b/spec/rubocop/cop/avoid_break_from_strong_memoize_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../rubocop/cop/avoid_break_from_strong_memoize' RSpec.describe RuboCop::Cop::AvoidBreakFromStrongMemoize do diff --git a/spec/rubocop/cop/avoid_keyword_arguments_in_sidekiq_workers_spec.rb b/spec/rubocop/cop/avoid_keyword_arguments_in_sidekiq_workers_spec.rb index 460a0b13458..90ee5772b66 100644 --- a/spec/rubocop/cop/avoid_keyword_arguments_in_sidekiq_workers_spec.rb +++ b/spec/rubocop/cop/avoid_keyword_arguments_in_sidekiq_workers_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../rubocop/cop/avoid_keyword_arguments_in_sidekiq_workers' RSpec.describe RuboCop::Cop::AvoidKeywordArgumentsInSidekiqWorkers do diff --git a/spec/rubocop/cop/avoid_return_from_blocks_spec.rb b/spec/rubocop/cop/avoid_return_from_blocks_spec.rb index 71311b9df7f..86098f1afcc 100644 --- a/spec/rubocop/cop/avoid_return_from_blocks_spec.rb +++ b/spec/rubocop/cop/avoid_return_from_blocks_spec.rb @@ -1,12 +1,9 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../rubocop/cop/avoid_return_from_blocks' RSpec.describe RuboCop::Cop::AvoidReturnFromBlocks do - include CopHelper - subject(:cop) { described_class.new } it 'flags violation for return inside a block' do @@ -19,20 +16,16 @@ RSpec.describe RuboCop::Cop::AvoidReturnFromBlocks do RUBY end - it "doesn't call add_offense twice for nested blocks" do - source = <<~RUBY + it "doesn't create more than one offense for nested blocks" do + expect_offense(<<~RUBY) call do call do something return if something_else + ^^^^^^ Do not return from a block, use next or break instead. end end RUBY - expect_any_instance_of(described_class) do |instance| - expect(instance).to receive(:add_offense).once - end - - inspect_source(source) end it 'flags violation for return inside included > def > block' do diff --git a/spec/rubocop/cop/avoid_route_redirect_leading_slash_spec.rb b/spec/rubocop/cop/avoid_route_redirect_leading_slash_spec.rb index 9e13a5278e3..61d6f45b5ba 100644 --- a/spec/rubocop/cop/avoid_route_redirect_leading_slash_spec.rb +++ b/spec/rubocop/cop/avoid_route_redirect_leading_slash_spec.rb @@ -1,23 +1,24 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../rubocop/cop/avoid_route_redirect_leading_slash' RSpec.describe RuboCop::Cop::AvoidRouteRedirectLeadingSlash do - include CopHelper - subject(:cop) { described_class.new } before do allow(cop).to receive(:in_routes?).and_return(true) end - it 'registers an offense when redirect has a leading slash' do + it 'registers an offense when redirect has a leading slash and corrects', :aggregate_failures do expect_offense(<<~PATTERN) root to: redirect("/-/route") ^^^^^^^^^^^^^^^^^^^^ Do not use a leading "/" in route redirects PATTERN + + expect_correction(<<~PATTERN) + root to: redirect("-/route") + PATTERN end it 'does not register an offense when redirect does not have a leading slash' do @@ -25,8 +26,4 @@ RSpec.describe RuboCop::Cop::AvoidRouteRedirectLeadingSlash do root to: redirect("-/route") PATTERN end - - it 'autocorrect `/-/route` to `-/route`' do - expect(autocorrect_source('redirect("/-/route")')).to eq('redirect("-/route")') - end end diff --git a/spec/rubocop/cop/ban_catch_throw_spec.rb b/spec/rubocop/cop/ban_catch_throw_spec.rb index b3c4ad8688c..f255d27e7c7 100644 --- a/spec/rubocop/cop/ban_catch_throw_spec.rb +++ b/spec/rubocop/cop/ban_catch_throw_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../rubocop/cop/ban_catch_throw' diff --git a/spec/rubocop/cop/code_reuse/finder_spec.rb b/spec/rubocop/cop/code_reuse/finder_spec.rb index 484a1549a89..36f44ca79da 100644 --- a/spec/rubocop/cop/code_reuse/finder_spec.rb +++ b/spec/rubocop/cop/code_reuse/finder_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/code_reuse/finder' RSpec.describe RuboCop::Cop::CodeReuse::Finder do diff --git a/spec/rubocop/cop/code_reuse/presenter_spec.rb b/spec/rubocop/cop/code_reuse/presenter_spec.rb index 4639854588e..070a7ed760c 100644 --- a/spec/rubocop/cop/code_reuse/presenter_spec.rb +++ b/spec/rubocop/cop/code_reuse/presenter_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/code_reuse/presenter' RSpec.describe RuboCop::Cop::CodeReuse::Presenter do diff --git a/spec/rubocop/cop/code_reuse/serializer_spec.rb b/spec/rubocop/cop/code_reuse/serializer_spec.rb index 84db2e62b41..d5577caa2b4 100644 --- a/spec/rubocop/cop/code_reuse/serializer_spec.rb +++ b/spec/rubocop/cop/code_reuse/serializer_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/code_reuse/serializer' RSpec.describe RuboCop::Cop::CodeReuse::Serializer do diff --git a/spec/rubocop/cop/code_reuse/service_class_spec.rb b/spec/rubocop/cop/code_reuse/service_class_spec.rb index b6d94dd749f..353225b2c42 100644 --- a/spec/rubocop/cop/code_reuse/service_class_spec.rb +++ b/spec/rubocop/cop/code_reuse/service_class_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/code_reuse/service_class' RSpec.describe RuboCop::Cop::CodeReuse::ServiceClass do diff --git a/spec/rubocop/cop/code_reuse/worker_spec.rb b/spec/rubocop/cop/code_reuse/worker_spec.rb index 42c9303a93b..8155791a3e3 100644 --- a/spec/rubocop/cop/code_reuse/worker_spec.rb +++ b/spec/rubocop/cop/code_reuse/worker_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/code_reuse/worker' RSpec.describe RuboCop::Cop::CodeReuse::Worker do diff --git a/spec/rubocop/cop/default_scope_spec.rb b/spec/rubocop/cop/default_scope_spec.rb index 506843e030e..4fac0d465e0 100644 --- a/spec/rubocop/cop/default_scope_spec.rb +++ b/spec/rubocop/cop/default_scope_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../rubocop/cop/default_scope' RSpec.describe RuboCop::Cop::DefaultScope do diff --git a/spec/rubocop/cop/destroy_all_spec.rb b/spec/rubocop/cop/destroy_all_spec.rb index f6850a00238..468b10c3816 100644 --- a/spec/rubocop/cop/destroy_all_spec.rb +++ b/spec/rubocop/cop/destroy_all_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../rubocop/cop/destroy_all' RSpec.describe RuboCop::Cop::DestroyAll do diff --git a/spec/rubocop/cop/filename_length_spec.rb b/spec/rubocop/cop/filename_length_spec.rb index 2411c8dbc7b..ee128cb2781 100644 --- a/spec/rubocop/cop/filename_length_spec.rb +++ b/spec/rubocop/cop/filename_length_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require 'rubocop/rspec/support' require_relative '../../../rubocop/cop/filename_length' diff --git a/spec/rubocop/cop/gitlab/avoid_uploaded_file_from_params_spec.rb b/spec/rubocop/cop/gitlab/avoid_uploaded_file_from_params_spec.rb index f96e25c59e7..6d69eb5456f 100644 --- a/spec/rubocop/cop/gitlab/avoid_uploaded_file_from_params_spec.rb +++ b/spec/rubocop/cop/gitlab/avoid_uploaded_file_from_params_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/gitlab/avoid_uploaded_file_from_params' RSpec.describe RuboCop::Cop::Gitlab::AvoidUploadedFileFromParams do diff --git a/spec/rubocop/cop/gitlab/bulk_insert_spec.rb b/spec/rubocop/cop/gitlab/bulk_insert_spec.rb index c280ab8fa8b..7c60518f890 100644 --- a/spec/rubocop/cop/gitlab/bulk_insert_spec.rb +++ b/spec/rubocop/cop/gitlab/bulk_insert_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/gitlab/bulk_insert' RSpec.describe RuboCop::Cop::Gitlab::BulkInsert do diff --git a/spec/rubocop/cop/gitlab/change_timezone_spec.rb b/spec/rubocop/cop/gitlab/change_timezone_spec.rb index 9cb822ec4f2..f3c07e44cc7 100644 --- a/spec/rubocop/cop/gitlab/change_timezone_spec.rb +++ b/spec/rubocop/cop/gitlab/change_timezone_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/gitlab/change_timzone' RSpec.describe RuboCop::Cop::Gitlab::ChangeTimezone do diff --git a/spec/rubocop/cop/gitlab/const_get_inherit_false_spec.rb b/spec/rubocop/cop/gitlab/const_get_inherit_false_spec.rb index 19e5fe946be..1d99ec93e25 100644 --- a/spec/rubocop/cop/gitlab/const_get_inherit_false_spec.rb +++ b/spec/rubocop/cop/gitlab/const_get_inherit_false_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/gitlab/const_get_inherit_false' RSpec.describe RuboCop::Cop::Gitlab::ConstGetInheritFalse do diff --git a/spec/rubocop/cop/gitlab/duplicate_spec_location_spec.rb b/spec/rubocop/cop/gitlab/duplicate_spec_location_spec.rb index a207155f432..3b3d5b01a30 100644 --- a/spec/rubocop/cop/gitlab/duplicate_spec_location_spec.rb +++ b/spec/rubocop/cop/gitlab/duplicate_spec_location_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/gitlab/duplicate_spec_location' diff --git a/spec/rubocop/cop/gitlab/except_spec.rb b/spec/rubocop/cop/gitlab/except_spec.rb index 7a122e3cf53..04cfe261cf2 100644 --- a/spec/rubocop/cop/gitlab/except_spec.rb +++ b/spec/rubocop/cop/gitlab/except_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/gitlab/except' RSpec.describe RuboCop::Cop::Gitlab::Except do diff --git a/spec/rubocop/cop/gitlab/finder_with_find_by_spec.rb b/spec/rubocop/cop/gitlab/finder_with_find_by_spec.rb index 03d7fc5e8b1..d2cd06d77c5 100644 --- a/spec/rubocop/cop/gitlab/finder_with_find_by_spec.rb +++ b/spec/rubocop/cop/gitlab/finder_with_find_by_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/gitlab/finder_with_find_by' diff --git a/spec/rubocop/cop/gitlab/httparty_spec.rb b/spec/rubocop/cop/gitlab/httparty_spec.rb index fcd18b0eb9b..98b1aa36586 100644 --- a/spec/rubocop/cop/gitlab/httparty_spec.rb +++ b/spec/rubocop/cop/gitlab/httparty_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/gitlab/httparty' RSpec.describe RuboCop::Cop::Gitlab::HTTParty do # rubocop:disable RSpec/FilePath diff --git a/spec/rubocop/cop/gitlab/intersect_spec.rb b/spec/rubocop/cop/gitlab/intersect_spec.rb index 6f0367591cd..f3cb1412f35 100644 --- a/spec/rubocop/cop/gitlab/intersect_spec.rb +++ b/spec/rubocop/cop/gitlab/intersect_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/gitlab/intersect' RSpec.describe RuboCop::Cop::Gitlab::Intersect do diff --git a/spec/rubocop/cop/gitlab/json_spec.rb b/spec/rubocop/cop/gitlab/json_spec.rb index 29c3b96cc1a..66b2c675e80 100644 --- a/spec/rubocop/cop/gitlab/json_spec.rb +++ b/spec/rubocop/cop/gitlab/json_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/gitlab/json' RSpec.describe RuboCop::Cop::Gitlab::Json do diff --git a/spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb b/spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb index 08634d5753a..d46dec3b2e3 100644 --- a/spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb +++ b/spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/gitlab/module_with_instance_variables' RSpec.describe RuboCop::Cop::Gitlab::ModuleWithInstanceVariables do diff --git a/spec/rubocop/cop/gitlab/namespaced_class_spec.rb b/spec/rubocop/cop/gitlab/namespaced_class_spec.rb index d1f61aa5afb..824a1b8cef5 100644 --- a/spec/rubocop/cop/gitlab/namespaced_class_spec.rb +++ b/spec/rubocop/cop/gitlab/namespaced_class_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require 'rubocop/rspec/support' require_relative '../../../../rubocop/cop/gitlab/namespaced_class' diff --git a/spec/rubocop/cop/gitlab/policy_rule_boolean_spec.rb b/spec/rubocop/cop/gitlab/policy_rule_boolean_spec.rb index 6dbbcdd8324..f73fc71b601 100644 --- a/spec/rubocop/cop/gitlab/policy_rule_boolean_spec.rb +++ b/spec/rubocop/cop/gitlab/policy_rule_boolean_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/gitlab/policy_rule_boolean' RSpec.describe RuboCop::Cop::Gitlab::PolicyRuleBoolean do diff --git a/spec/rubocop/cop/gitlab/predicate_memoization_spec.rb b/spec/rubocop/cop/gitlab/predicate_memoization_spec.rb index 071ddcf8b7d..903c02ba194 100644 --- a/spec/rubocop/cop/gitlab/predicate_memoization_spec.rb +++ b/spec/rubocop/cop/gitlab/predicate_memoization_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/gitlab/predicate_memoization' RSpec.describe RuboCop::Cop::Gitlab::PredicateMemoization do diff --git a/spec/rubocop/cop/gitlab/rails_logger_spec.rb b/spec/rubocop/cop/gitlab/rails_logger_spec.rb index 7258b047191..24f49bf3044 100644 --- a/spec/rubocop/cop/gitlab/rails_logger_spec.rb +++ b/spec/rubocop/cop/gitlab/rails_logger_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/gitlab/rails_logger' RSpec.describe RuboCop::Cop::Gitlab::RailsLogger do diff --git a/spec/rubocop/cop/gitlab/union_spec.rb b/spec/rubocop/cop/gitlab/union_spec.rb index 04a3db8e7dd..ce84c75338d 100644 --- a/spec/rubocop/cop/gitlab/union_spec.rb +++ b/spec/rubocop/cop/gitlab/union_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/gitlab/union' RSpec.describe RuboCop::Cop::Gitlab::Union do diff --git a/spec/rubocop/cop/graphql/authorize_types_spec.rb b/spec/rubocop/cop/graphql/authorize_types_spec.rb index 9242b865b20..6c521789e34 100644 --- a/spec/rubocop/cop/graphql/authorize_types_spec.rb +++ b/spec/rubocop/cop/graphql/authorize_types_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/graphql/authorize_types' @@ -63,4 +62,34 @@ RSpec.describe RuboCop::Cop::Graphql::AuthorizeTypes do end TYPE end + + it 'does not add an offense for subtypes of BaseUnion' do + expect_no_offenses(<<~TYPE) + module Types + class AType < BaseUnion + possible_types Types::Foo, Types::Bar + end + end + TYPE + end + + it 'does not add an offense for subtypes of BaseInputObject' do + expect_no_offenses(<<~TYPE) + module Types + class AType < BaseInputObject + argument :a_thing + end + end + TYPE + end + + it 'does not add an offense for InputTypes' do + expect_no_offenses(<<~TYPE) + module Types + class AInputType < SomeObjectType + argument :a_thing + end + end + TYPE + end end diff --git a/spec/rubocop/cop/graphql/descriptions_spec.rb b/spec/rubocop/cop/graphql/descriptions_spec.rb index 9ad40fad83d..af660aee165 100644 --- a/spec/rubocop/cop/graphql/descriptions_spec.rb +++ b/spec/rubocop/cop/graphql/descriptions_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/graphql/descriptions' RSpec.describe RuboCop::Cop::Graphql::Descriptions do @@ -91,6 +90,50 @@ RSpec.describe RuboCop::Cop::Graphql::Descriptions do end end + context 'enum values' do + it 'adds an offense when there is no description' do + expect_offense(<<~TYPE) + module Types + class FakeEnum < BaseEnum + value 'FOO', value: 'foo' + ^^^^^^^^^^^^^^^^^^^^^^^^^ Please add a `description` property. + end + end + TYPE + end + + it 'adds an offense when description does not end in a period' do + expect_offense(<<~TYPE) + module Types + class FakeEnum < BaseEnum + value 'FOO', value: 'foo', description: 'bar' + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `description` strings must end with a `.`. + end + end + TYPE + end + + it 'does not add an offense when description is correct (defined using `description:`)' do + expect_no_offenses(<<~TYPE.strip) + module Types + class FakeEnum < BaseEnum + value 'FOO', value: 'foo', description: 'bar.' + end + end + TYPE + end + + it 'does not add an offense when description is correct (defined as a second argument)' do + expect_no_offenses(<<~TYPE.strip) + module Types + class FakeEnum < BaseEnum + value 'FOO', 'bar.', value: 'foo' + end + end + TYPE + end + end + describe 'autocorrecting descriptions without periods' do it 'can autocorrect' do expect_offense(<<~TYPE) diff --git a/spec/rubocop/cop/graphql/gid_expected_type_spec.rb b/spec/rubocop/cop/graphql/gid_expected_type_spec.rb index d9a129244d6..47a6ce24d53 100644 --- a/spec/rubocop/cop/graphql/gid_expected_type_spec.rb +++ b/spec/rubocop/cop/graphql/gid_expected_type_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/graphql/gid_expected_type' diff --git a/spec/rubocop/cop/graphql/id_type_spec.rb b/spec/rubocop/cop/graphql/id_type_spec.rb index 93c01cd7f06..a566488b118 100644 --- a/spec/rubocop/cop/graphql/id_type_spec.rb +++ b/spec/rubocop/cop/graphql/id_type_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/graphql/id_type' diff --git a/spec/rubocop/cop/graphql/json_type_spec.rb b/spec/rubocop/cop/graphql/json_type_spec.rb index 91838c1708e..50437953c1d 100644 --- a/spec/rubocop/cop/graphql/json_type_spec.rb +++ b/spec/rubocop/cop/graphql/json_type_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/graphql/json_type' RSpec.describe RuboCop::Cop::Graphql::JSONType do diff --git a/spec/rubocop/cop/graphql/resolver_type_spec.rb b/spec/rubocop/cop/graphql/resolver_type_spec.rb index 11c0ad284a9..06bf90a8a07 100644 --- a/spec/rubocop/cop/graphql/resolver_type_spec.rb +++ b/spec/rubocop/cop/graphql/resolver_type_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/graphql/resolver_type' diff --git a/spec/rubocop/cop/group_public_or_visible_to_user_spec.rb b/spec/rubocop/cop/group_public_or_visible_to_user_spec.rb index b3ec426dc07..2348552f9e4 100644 --- a/spec/rubocop/cop/group_public_or_visible_to_user_spec.rb +++ b/spec/rubocop/cop/group_public_or_visible_to_user_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../rubocop/cop/group_public_or_visible_to_user' RSpec.describe RuboCop::Cop::GroupPublicOrVisibleToUser do diff --git a/spec/rubocop/cop/ignored_columns_spec.rb b/spec/rubocop/cop/ignored_columns_spec.rb index 38b4ac0bc1a..1c72fedbf31 100644 --- a/spec/rubocop/cop/ignored_columns_spec.rb +++ b/spec/rubocop/cop/ignored_columns_spec.rb @@ -1,22 +1,17 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' -require 'rubocop/rspec/support' require_relative '../../../rubocop/cop/ignored_columns' RSpec.describe RuboCop::Cop::IgnoredColumns do - include CopHelper - subject(:cop) { described_class.new } - it 'flags the use of destroy_all with a local variable receiver' do - inspect_source(<<~RUBY) + it 'flags direct use of ignored_columns instead of the IgnoredColumns concern' do + expect_offense(<<~RUBY) class Foo < ApplicationRecord self.ignored_columns += %i[id] + ^^^^^^^^^^^^^^^^^^^^ Use `IgnoredColumns` concern instead of adding to `self.ignored_columns`. end RUBY - - expect(cop.offenses.size).to eq(1) end end diff --git a/spec/rubocop/cop/include_sidekiq_worker_spec.rb b/spec/rubocop/cop/include_sidekiq_worker_spec.rb index bdd622d4894..8c706925ab9 100644 --- a/spec/rubocop/cop/include_sidekiq_worker_spec.rb +++ b/spec/rubocop/cop/include_sidekiq_worker_spec.rb @@ -2,7 +2,6 @@ require 'fast_spec_helper' -require 'rubocop' require_relative '../../../rubocop/cop/include_sidekiq_worker' RSpec.describe RuboCop::Cop::IncludeSidekiqWorker do diff --git a/spec/rubocop/cop/inject_enterprise_edition_module_spec.rb b/spec/rubocop/cop/inject_enterprise_edition_module_spec.rb index 2d293fd0a05..8bfa57031d7 100644 --- a/spec/rubocop/cop/inject_enterprise_edition_module_spec.rb +++ b/spec/rubocop/cop/inject_enterprise_edition_module_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../rubocop/cop/inject_enterprise_edition_module' RSpec.describe RuboCop::Cop::InjectEnterpriseEditionModule do diff --git a/spec/rubocop/cop/lint/last_keyword_argument_spec.rb b/spec/rubocop/cop/lint/last_keyword_argument_spec.rb index aac59f0db4c..b1b4c88e0f6 100644 --- a/spec/rubocop/cop/lint/last_keyword_argument_spec.rb +++ b/spec/rubocop/cop/lint/last_keyword_argument_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/lint/last_keyword_argument' RSpec.describe RuboCop::Cop::Lint::LastKeywordArgument do diff --git a/spec/rubocop/cop/migration/add_column_with_default_spec.rb b/spec/rubocop/cop/migration/add_column_with_default_spec.rb index cf476ae55d6..3f47613280f 100644 --- a/spec/rubocop/cop/migration/add_column_with_default_spec.rb +++ b/spec/rubocop/cop/migration/add_column_with_default_spec.rb @@ -1,15 +1,12 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/add_column_with_default' RSpec.describe RuboCop::Cop::Migration::AddColumnWithDefault do - include CopHelper - let(:cop) { described_class.new } - context 'outside of a migration' do + context 'when outside of a migration' do it 'does not register any offenses' do expect_no_offenses(<<~RUBY) def up @@ -19,18 +16,16 @@ RSpec.describe RuboCop::Cop::Migration::AddColumnWithDefault do end end - context 'in a migration' do + context 'when in a migration' do before do allow(cop).to receive(:in_migration?).and_return(true) end - let(:offense) { '`add_column_with_default` is deprecated, use `add_column` instead' } - it 'registers an offense' do expect_offense(<<~RUBY) def up add_column_with_default(:merge_request_diff_files, :artifacts, :boolean, default: true, allow_null: false) - ^^^^^^^^^^^^^^^^^^^^^^^ #{offense} + ^^^^^^^^^^^^^^^^^^^^^^^ `add_column_with_default` is deprecated, use `add_column` instead end RUBY end diff --git a/spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb b/spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb index 92863c45b1a..b78ec971245 100644 --- a/spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb +++ b/spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb @@ -1,15 +1,12 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/add_columns_to_wide_tables' RSpec.describe RuboCop::Cop::Migration::AddColumnsToWideTables do - include CopHelper - let(:cop) { described_class.new } - context 'outside of a migration' do + context 'when outside of a migration' do it 'does not register any offenses' do expect_no_offenses(<<~RUBY) def up @@ -19,14 +16,14 @@ RSpec.describe RuboCop::Cop::Migration::AddColumnsToWideTables do end end - context 'in a migration' do + context 'when in a migration' do before do allow(cop).to receive(:in_migration?).and_return(true) end context 'with wide tables' do it 'registers an offense when adding a column to a wide table' do - offense = '`projects` is a wide table with several columns, addig more should be avoided unless absolutely necessary. Consider storing the column in a different table or creating a new one.' + offense = '`projects` is a wide table with several columns, [...]' expect_offense(<<~RUBY) def up @@ -37,7 +34,7 @@ RSpec.describe RuboCop::Cop::Migration::AddColumnsToWideTables do end it 'registers an offense when adding a column with default to a wide table' do - offense = '`users` is a wide table with several columns, addig more should be avoided unless absolutely necessary. Consider storing the column in a different table or creating a new one.' + offense = '`users` is a wide table with several columns, [...]' expect_offense(<<~RUBY) def up @@ -48,7 +45,7 @@ RSpec.describe RuboCop::Cop::Migration::AddColumnsToWideTables do end it 'registers an offense when adding a reference' do - offense = '`ci_builds` is a wide table with several columns, addig more should be avoided unless absolutely necessary. Consider storing the column in a different table or creating a new one.' + offense = '`ci_builds` is a wide table with several columns, [...]' expect_offense(<<~RUBY) def up @@ -59,7 +56,7 @@ RSpec.describe RuboCop::Cop::Migration::AddColumnsToWideTables do end it 'registers an offense when adding timestamps' do - offense = '`projects` is a wide table with several columns, addig more should be avoided unless absolutely necessary. Consider storing the column in a different table or creating a new one.' + offense = '`projects` is a wide table with several columns, [...]' expect_offense(<<~RUBY) def up diff --git a/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb b/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb index 25350ad1ecb..572c0d414b3 100644 --- a/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb +++ b/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb @@ -1,50 +1,41 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/add_concurrent_foreign_key' RSpec.describe RuboCop::Cop::Migration::AddConcurrentForeignKey do - include CopHelper - let(:cop) { described_class.new } - context 'outside of a migration' do + context 'when outside of a migration' do it 'does not register any offenses' do - inspect_source('def up; add_foreign_key(:projects, :users, column: :user_id); end') - - expect(cop.offenses).to be_empty + expect_no_offenses('def up; add_foreign_key(:projects, :users, column: :user_id); end') end end - context 'in a migration' do + context 'when in a migration' do before do allow(cop).to receive(:in_migration?).and_return(true) end it 'registers an offense when using add_foreign_key' do - inspect_source('def up; add_foreign_key(:projects, :users, column: :user_id); end') - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([1]) - end + expect_offense(<<~RUBY) + def up + add_foreign_key(:projects, :users, column: :user_id) + ^^^^^^^^^^^^^^^ `add_foreign_key` requires downtime, use `add_concurrent_foreign_key` instead + end + RUBY end it 'does not register an offense when a `NOT VALID` foreign key is added' do - inspect_source('def up; add_foreign_key(:projects, :users, column: :user_id, validate: false); end') - - expect(cop.offenses).to be_empty + expect_no_offenses('def up; add_foreign_key(:projects, :users, column: :user_id, validate: false); end') end it 'does not register an offense when `add_foreign_key` is within `with_lock_retries`' do - inspect_source <<~RUBY + expect_no_offenses(<<~RUBY) with_lock_retries do add_foreign_key :key, :projects, column: :project_id, on_delete: :cascade end RUBY - - expect(cop.offenses).to be_empty end end end diff --git a/spec/rubocop/cop/migration/add_concurrent_index_spec.rb b/spec/rubocop/cop/migration/add_concurrent_index_spec.rb index 351283a230a..52b3a5769ff 100644 --- a/spec/rubocop/cop/migration/add_concurrent_index_spec.rb +++ b/spec/rubocop/cop/migration/add_concurrent_index_spec.rb @@ -1,40 +1,33 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/add_concurrent_index' RSpec.describe RuboCop::Cop::Migration::AddConcurrentIndex do - include CopHelper - subject(:cop) { described_class.new } - context 'in migration' do + context 'when in migration' do before do allow(cop).to receive(:in_migration?).and_return(true) end it 'registers an offense when add_concurrent_index is used inside a change method' do - inspect_source('def change; add_concurrent_index :table, :column; end') - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([1]) - end + expect_offense(<<~RUBY) + def change + ^^^^^^ `add_concurrent_index` is not reversible[...] + add_concurrent_index :table, :column + end + RUBY end it 'registers no offense when add_concurrent_index is used inside an up method' do - inspect_source('def up; add_concurrent_index :table, :column; end') - - expect(cop.offenses.size).to eq(0) + expect_no_offenses('def up; add_concurrent_index :table, :column; end') end end - context 'outside of migration' do + context 'when outside of migration' do it 'registers no offense' do - inspect_source('def change; add_concurrent_index :table, :column; end') - - expect(cop.offenses.size).to eq(0) + expect_no_offenses('def change; add_concurrent_index :table, :column; end') end end end diff --git a/spec/rubocop/cop/migration/add_index_spec.rb b/spec/rubocop/cop/migration/add_index_spec.rb index 1d083e9f2d2..088bfe434f4 100644 --- a/spec/rubocop/cop/migration/add_index_spec.rb +++ b/spec/rubocop/cop/migration/add_index_spec.rb @@ -1,12 +1,9 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/add_index' RSpec.describe RuboCop::Cop::Migration::AddIndex do - include CopHelper - subject(:cop) { described_class.new } context 'in migration' do diff --git a/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb b/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb index 149fb0a48eb..f4695ff8d2d 100644 --- a/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb +++ b/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb @@ -1,15 +1,14 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/add_limit_to_text_columns' RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do - include CopHelper - subject(:cop) { described_class.new } - context 'in migration' do + context 'when in migration' do + let(:msg) { 'Text columns should always have a limit set (255 is suggested)[...]' } + before do allow(cop).to receive(:in_migration?).and_return(true) end @@ -25,31 +24,29 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do create_table :test_text_limits, id: false do |t| t.integer :test_id, null: false t.text :name - ^^^^ #{described_class::MSG} + ^^^^ #{msg} end create_table_with_constraints :test_text_limits_create do |t| t.integer :test_id, null: false t.text :title t.text :description - ^^^^ #{described_class::MSG} + ^^^^ #{msg} t.text_limit :title, 100 end add_column :test_text_limits, :email, :text - ^^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^^ #{msg} add_column_with_default :test_text_limits, :role, :text, default: 'default' - ^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^^^^^^^^^^^^^^^ #{msg} change_column_type_concurrently :test_text_limits, :test_id, :text - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg} end end RUBY - - expect(cop.offenses.map(&:cop_name)).to all(eq('Migration/AddLimitToTextColumns')) end end @@ -111,7 +108,7 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do end # Make sure that the cop is properly checking for an `add_text_limit` - # over the same {table, attribute} as the one that triggered the offence + # over the same {table, attribute} as the one that triggered the offense context 'when the limit is defined for a same name attribute but different table' do it 'registers an offense' do expect_offense(<<~RUBY) @@ -123,17 +120,17 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do create_table :test_text_limits, id: false do |t| t.integer :test_id, null: false t.text :name - ^^^^ #{described_class::MSG} + ^^^^ #{msg} end add_column :test_text_limits, :email, :text - ^^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^^ #{msg} add_column_with_default :test_text_limits, :role, :text, default: 'default' - ^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^^^^^^^^^^^^^^^ #{msg} change_column_type_concurrently :test_text_limits, :test_id, :text - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg} add_text_limit :wrong_table, :name, 255 add_text_limit :wrong_table, :email, 255 @@ -142,8 +139,6 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do end end RUBY - - expect(cop.offenses.map(&:cop_name)).to all(eq('Migration/AddLimitToTextColumns')) end end @@ -176,18 +171,18 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do DOWNTIME = false def up - drop_table :no_offence_on_down + drop_table :no_offense_on_down end def down - create_table :no_offence_on_down, id: false do |t| + create_table :no_offense_on_down, id: false do |t| t.integer :test_id, null: false t.text :name end - add_column :no_offence_on_down, :email, :text + add_column :no_offense_on_down, :email, :text - add_column_with_default :no_offence_on_down, :role, :text, default: 'default' + add_column_with_default :no_offense_on_down, :role, :text, default: 'default' end end RUBY @@ -195,7 +190,7 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do end end - context 'outside of migration' do + context 'when outside of migration' do it 'registers no offense' do expect_no_offenses(<<~RUBY) class TestTextLimits < ActiveRecord::Migration[6.0] diff --git a/spec/rubocop/cop/migration/add_reference_spec.rb b/spec/rubocop/cop/migration/add_reference_spec.rb index 6e229d3eefc..9445780e9ed 100644 --- a/spec/rubocop/cop/migration/add_reference_spec.rb +++ b/spec/rubocop/cop/migration/add_reference_spec.rb @@ -1,15 +1,12 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/add_reference' RSpec.describe RuboCop::Cop::Migration::AddReference do - include CopHelper - let(:cop) { described_class.new } - context 'outside of a migration' do + context 'when outside of a migration' do it 'does not register any offenses' do expect_no_offenses(<<~RUBY) def up @@ -19,12 +16,12 @@ RSpec.describe RuboCop::Cop::Migration::AddReference do end end - context 'in a migration' do + context 'when in a migration' do before do allow(cop).to receive(:in_migration?).and_return(true) end - let(:offense) { '`add_reference` requires downtime for existing tables, use `add_concurrent_foreign_key` instead. When used for new tables, `index: true` or `index: { options... } is required.`' } + let(:offense) { '`add_reference` requires downtime for existing tables, use `add_concurrent_foreign_key`[...]' } context 'when the table existed before' do it 'registers an offense when using add_reference' do diff --git a/spec/rubocop/cop/migration/add_timestamps_spec.rb b/spec/rubocop/cop/migration/add_timestamps_spec.rb index 83570711ab9..ef5a856722f 100644 --- a/spec/rubocop/cop/migration/add_timestamps_spec.rb +++ b/spec/rubocop/cop/migration/add_timestamps_spec.rb @@ -1,12 +1,9 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/add_timestamps' RSpec.describe RuboCop::Cop::Migration::AddTimestamps do - include CopHelper - subject(:cop) { described_class.new } let(:migration_with_add_timestamps) do @@ -47,44 +44,39 @@ RSpec.describe RuboCop::Cop::Migration::AddTimestamps do ) end - context 'in migration' do + context 'when in migration' do before do allow(cop).to receive(:in_migration?).and_return(true) end it 'registers an offense when the "add_timestamps" method is used' do - inspect_source(migration_with_add_timestamps) - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([7]) - end + expect_offense(<<~RUBY) + class Users < ActiveRecord::Migration[4.2] + DOWNTIME = false + + def change + add_column(:users, :username, :text) + add_timestamps(:users) + ^^^^^^^^^^^^^^ Do not use `add_timestamps`, use `add_timestamps_with_timezone` instead + end + end + RUBY end it 'does not register an offense when the "add_timestamps" method is not used' do - inspect_source(migration_without_add_timestamps) - - aggregate_failures do - expect(cop.offenses.size).to eq(0) - end + expect_no_offenses(migration_without_add_timestamps) end it 'does not register an offense when the "add_timestamps_with_timezone" method is used' do - inspect_source(migration_with_add_timestamps_with_timezone) - - aggregate_failures do - expect(cop.offenses.size).to eq(0) - end + expect_no_offenses(migration_with_add_timestamps_with_timezone) end end - context 'outside of migration' do - it 'registers no offense' do - inspect_source(migration_with_add_timestamps) - inspect_source(migration_without_add_timestamps) - inspect_source(migration_with_add_timestamps_with_timezone) - - expect(cop.offenses.size).to eq(0) + context 'when outside of migration' do + it 'registers no offense', :aggregate_failures do + expect_no_offenses(migration_with_add_timestamps) + expect_no_offenses(migration_without_add_timestamps) + expect_no_offenses(migration_with_add_timestamps_with_timezone) end end end diff --git a/spec/rubocop/cop/migration/complex_indexes_require_name_spec.rb b/spec/rubocop/cop/migration/complex_indexes_require_name_spec.rb index 38ccf546b7c..15e947a1e53 100644 --- a/spec/rubocop/cop/migration/complex_indexes_require_name_spec.rb +++ b/spec/rubocop/cop/migration/complex_indexes_require_name_spec.rb @@ -1,15 +1,14 @@ # frozen_string_literal: true # require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/complex_indexes_require_name' RSpec.describe RuboCop::Cop::Migration::ComplexIndexesRequireName do - include CopHelper - subject(:cop) { described_class.new } - context 'in migration' do + context 'when in migration' do + let(:msg) { 'indexes added with custom options must be explicitly named' } + before do allow(cop).to receive(:in_migration?).and_return(true) end @@ -29,9 +28,9 @@ RSpec.describe RuboCop::Cop::Migration::ComplexIndexesRequireName do t.index :column1, unique: true t.index :column2, where: 'column1 = 0' - ^^^^^ #{described_class::MSG} + ^^^^^ #{msg} t.index :column3, using: :gin - ^^^^^ #{described_class::MSG} + ^^^^^ #{msg} end end @@ -40,8 +39,6 @@ RSpec.describe RuboCop::Cop::Migration::ComplexIndexesRequireName do end end RUBY - - expect(cop.offenses.map(&:cop_name)).to all(eq("Migration/#{described_class.name.demodulize}")) end end @@ -85,20 +82,18 @@ RSpec.describe RuboCop::Cop::Migration::ComplexIndexesRequireName do add_index :test_indexes, :column1 add_index :test_indexes, :column2, where: "column2 = 'value'", order: { column4: :desc } - ^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^ #{msg} end def down add_index :test_indexes, :column4, 'unique' => true, where: 'column4 IS NOT NULL' - ^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^ #{msg} add_concurrent_index :test_indexes, :column6, using: :gin, opclass: :gin_trgm_ops - ^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^^^^^^^^^^^^ #{msg} end end RUBY - - expect(cop.offenses.map(&:cop_name)).to all(eq("Migration/#{described_class.name.demodulize}")) end end @@ -132,7 +127,7 @@ RSpec.describe RuboCop::Cop::Migration::ComplexIndexesRequireName do end end - context 'outside migration' do + context 'when outside migration' do before do allow(cop).to receive(:in_migration?).and_return(false) end diff --git a/spec/rubocop/cop/migration/create_table_with_foreign_keys_spec.rb b/spec/rubocop/cop/migration/create_table_with_foreign_keys_spec.rb index 2159bad1490..7bcaf36b014 100644 --- a/spec/rubocop/cop/migration/create_table_with_foreign_keys_spec.rb +++ b/spec/rubocop/cop/migration/create_table_with_foreign_keys_spec.rb @@ -1,12 +1,9 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/create_table_with_foreign_keys' RSpec.describe RuboCop::Cop::Migration::CreateTableWithForeignKeys do - include CopHelper - let(:cop) { described_class.new } context 'outside of a migration' do @@ -22,7 +19,7 @@ RSpec.describe RuboCop::Cop::Migration::CreateTableWithForeignKeys do end end - context 'in a migration' do + context 'when in a migration' do before do allow(cop).to receive(:in_migration?).and_return(true) end diff --git a/spec/rubocop/cop/migration/datetime_spec.rb b/spec/rubocop/cop/migration/datetime_spec.rb index a3cccae21e0..3854ddfe99c 100644 --- a/spec/rubocop/cop/migration/datetime_spec.rb +++ b/spec/rubocop/cop/migration/datetime_spec.rb @@ -1,44 +1,11 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/datetime' RSpec.describe RuboCop::Cop::Migration::Datetime do - include CopHelper - subject(:cop) { described_class.new } - let(:create_table_migration_with_datetime) do - %q( - class Users < ActiveRecord::Migration[6.0] - DOWNTIME = false - - def change - create_table :users do |t| - t.string :username, null: false - t.datetime :last_sign_in - end - end - end - ) - end - - let(:create_table_migration_with_timestamp) do - %q( - class Users < ActiveRecord::Migration[6.0] - DOWNTIME = false - - def change - create_table :users do |t| - t.string :username, null: false - t.timestamp :last_sign_in - end - end - end - ) - end - let(:create_table_migration_without_datetime) do %q( class Users < ActiveRecord::Migration[6.0] @@ -120,92 +87,94 @@ RSpec.describe RuboCop::Cop::Migration::Datetime do ) end - context 'in migration' do + context 'when in migration' do before do allow(cop).to receive(:in_migration?).and_return(true) end it 'registers an offense when the ":datetime" data type is used on create_table' do - inspect_source(create_table_migration_with_datetime) - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([8]) - expect(cop.offenses.first.message).to include('`datetime`') - end + expect_offense(<<~RUBY) + class Users < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def change + create_table :users do |t| + t.string :username, null: false + t.datetime :last_sign_in + ^^^^^^^^ Do not use the `datetime` data type[...] + end + end + end + RUBY end it 'registers an offense when the ":timestamp" data type is used on create_table' do - inspect_source(create_table_migration_with_timestamp) - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([8]) - expect(cop.offenses.first.message).to include('timestamp') - end + expect_offense(<<~RUBY) + class Users < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def change + create_table :users do |t| + t.string :username, null: false + t.timestamp :last_sign_in + ^^^^^^^^^ Do not use the `timestamp` data type[...] + end + end + end + RUBY end it 'does not register an offense when the ":datetime" data type is not used on create_table' do - inspect_source(create_table_migration_without_datetime) - - aggregate_failures do - expect(cop.offenses.size).to eq(0) - end + expect_no_offenses(create_table_migration_without_datetime) end it 'does not register an offense when the ":datetime_with_timezone" data type is used on create_table' do - inspect_source(create_table_migration_with_datetime_with_timezone) - - aggregate_failures do - expect(cop.offenses.size).to eq(0) - end + expect_no_offenses(create_table_migration_with_datetime_with_timezone) end it 'registers an offense when the ":datetime" data type is used on add_column' do - inspect_source(add_column_migration_with_datetime) - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([7]) - expect(cop.offenses.first.message).to include('`datetime`') - end + expect_offense(<<~RUBY) + class Users < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def change + add_column(:users, :username, :text) + add_column(:users, :last_sign_in, :datetime) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Do not use the `datetime` data type[...] + end + end + RUBY end it 'registers an offense when the ":timestamp" data type is used on add_column' do - inspect_source(add_column_migration_with_timestamp) - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([7]) - expect(cop.offenses.first.message).to include('timestamp') - end + expect_offense(<<~RUBY) + class Users < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def change + add_column(:users, :username, :text) + add_column(:users, :last_sign_in, :timestamp) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Do not use the `timestamp` data type[...] + end + end + RUBY end it 'does not register an offense when the ":datetime" data type is not used on add_column' do - inspect_source(add_column_migration_without_datetime) - - aggregate_failures do - expect(cop.offenses.size).to eq(0) - end + expect_no_offenses(add_column_migration_without_datetime) end it 'does not register an offense when the ":datetime_with_timezone" data type is used on add_column' do - inspect_source(add_column_migration_with_datetime_with_timezone) - - aggregate_failures do - expect(cop.offenses.size).to eq(0) - end + expect_no_offenses(add_column_migration_with_datetime_with_timezone) end end - context 'outside of migration' do - it 'registers no offense' do - inspect_source(add_column_migration_with_datetime) - inspect_source(add_column_migration_with_timestamp) - inspect_source(add_column_migration_without_datetime) - inspect_source(add_column_migration_with_datetime_with_timezone) - - expect(cop.offenses.size).to eq(0) + context 'when outside of migration' do + it 'registers no offense', :aggregate_failures do + expect_no_offenses(add_column_migration_with_datetime) + expect_no_offenses(add_column_migration_with_timestamp) + expect_no_offenses(add_column_migration_without_datetime) + expect_no_offenses(add_column_migration_with_datetime_with_timezone) end end end diff --git a/spec/rubocop/cop/migration/drop_table_spec.rb b/spec/rubocop/cop/migration/drop_table_spec.rb index d783cb56203..f1bd710f5e6 100644 --- a/spec/rubocop/cop/migration/drop_table_spec.rb +++ b/spec/rubocop/cop/migration/drop_table_spec.rb @@ -1,15 +1,16 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/drop_table' RSpec.describe RuboCop::Cop::Migration::DropTable do - include CopHelper - subject(:cop) { described_class.new } context 'when in deployment migration' do + let(:msg) do + '`drop_table` in deployment migrations requires downtime. Drop tables in post-deployment migrations instead.' + end + before do allow(cop).to receive(:in_deployment_migration?).and_return(true) end @@ -30,7 +31,7 @@ RSpec.describe RuboCop::Cop::Migration::DropTable do expect_offense(<<~PATTERN) def up drop_table :table - ^^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^^ #{msg} end PATTERN end @@ -41,7 +42,7 @@ RSpec.describe RuboCop::Cop::Migration::DropTable do expect_offense(<<~PATTERN) def change drop_table :table - ^^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^^ #{msg} end PATTERN end @@ -63,7 +64,7 @@ RSpec.describe RuboCop::Cop::Migration::DropTable do expect_offense(<<~PATTERN) def up execute "DROP TABLE table" - ^^^^^^^ #{described_class::MSG} + ^^^^^^^ #{msg} end PATTERN end @@ -74,7 +75,7 @@ RSpec.describe RuboCop::Cop::Migration::DropTable do expect_offense(<<~PATTERN) def change execute "DROP TABLE table" - ^^^^^^^ #{described_class::MSG} + ^^^^^^^ #{msg} end PATTERN end diff --git a/spec/rubocop/cop/migration/hash_index_spec.rb b/spec/rubocop/cop/migration/hash_index_spec.rb index 15f68eb990f..6da27af39b6 100644 --- a/spec/rubocop/cop/migration/hash_index_spec.rb +++ b/spec/rubocop/cop/migration/hash_index_spec.rb @@ -1,52 +1,47 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/hash_index' RSpec.describe RuboCop::Cop::Migration::HashIndex do - include CopHelper - subject(:cop) { described_class.new } - context 'in migration' do + context 'when in migration' do before do allow(cop).to receive(:in_migration?).and_return(true) end it 'registers an offense when creating a hash index' do - inspect_source('def change; add_index :table, :column, using: :hash; end') - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([1]) - end + expect_offense(<<~RUBY) + def change + add_index :table, :column, using: :hash + ^^^^^^^^^^^^ hash indexes should be avoided at all costs[...] + end + RUBY end it 'registers an offense when creating a concurrent hash index' do - inspect_source('def change; add_concurrent_index :table, :column, using: :hash; end') - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([1]) - end + expect_offense(<<~RUBY) + def change + add_concurrent_index :table, :column, using: :hash + ^^^^^^^^^^^^ hash indexes should be avoided at all costs[...] + end + RUBY end it 'registers an offense when creating a hash index using t.index' do - inspect_source('def change; t.index :table, :column, using: :hash; end') - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([1]) - end + expect_offense(<<~RUBY) + def change + t.index :table, :column, using: :hash + ^^^^^^^^^^^^ hash indexes should be avoided at all costs[...] + end + RUBY end end - context 'outside of migration' do + context 'when outside of migration' do it 'registers no offense' do - inspect_source('def change; index :table, :column, using: :hash; end') - - expect(cop.offenses.size).to eq(0) + expect_no_offenses('def change; index :table, :column, using: :hash; end') end end end diff --git a/spec/rubocop/cop/migration/prevent_strings_spec.rb b/spec/rubocop/cop/migration/prevent_strings_spec.rb index 560a485017a..a9b62f23a77 100644 --- a/spec/rubocop/cop/migration/prevent_strings_spec.rb +++ b/spec/rubocop/cop/migration/prevent_strings_spec.rb @@ -1,49 +1,44 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/prevent_strings' RSpec.describe RuboCop::Cop::Migration::PreventStrings do - include CopHelper - subject(:cop) { described_class.new } - context 'in migration' do + context 'when in migration' do before do allow(cop).to receive(:in_migration?).and_return(true) end context 'when the string data type is used' do it 'registers an offense' do - expect_offense(<<~RUBY) + expect_offense(<<~RUBY, msg: "Do not use the `string` data type, use `text` instead.[...]") class Users < ActiveRecord::Migration[6.0] DOWNTIME = false def up create_table :users do |t| t.string :username, null: false - ^^^^^^ #{described_class::MSG} + ^^^^^^ %{msg} t.timestamps_with_timezone null: true t.string :password - ^^^^^^ #{described_class::MSG} + ^^^^^^ %{msg} end add_column(:users, :bio, :string) - ^^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^^ %{msg} add_column_with_default(:users, :url, :string, default: '/-/user', allow_null: false, limit: 255) - ^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^^^^^^^^^^^^^^^ %{msg} change_column_type_concurrently :users, :commit_id, :string - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ %{msg} end end RUBY - - expect(cop.offenses.map(&:cop_name)).to all(eq('Migration/PreventStrings')) end end @@ -109,7 +104,7 @@ RSpec.describe RuboCop::Cop::Migration::PreventStrings do end end - context 'on down' do + context 'when using down method' do it 'registers no offense' do expect_no_offenses(<<~RUBY) class Users < ActiveRecord::Migration[6.0] @@ -138,7 +133,7 @@ RSpec.describe RuboCop::Cop::Migration::PreventStrings do end end - context 'outside of migration' do + context 'when outside of migration' do it 'registers no offense' do expect_no_offenses(<<~RUBY) class Users < ActiveRecord::Migration[6.0] diff --git a/spec/rubocop/cop/migration/refer_to_index_by_name_spec.rb b/spec/rubocop/cop/migration/refer_to_index_by_name_spec.rb index a25328a56a8..b3e66492d83 100644 --- a/spec/rubocop/cop/migration/refer_to_index_by_name_spec.rb +++ b/spec/rubocop/cop/migration/refer_to_index_by_name_spec.rb @@ -1,22 +1,19 @@ # frozen_string_literal: true # require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/refer_to_index_by_name' RSpec.describe RuboCop::Cop::Migration::ReferToIndexByName do - include CopHelper - subject(:cop) { described_class.new } - context 'in migration' do + context 'when in migration' do before do allow(cop).to receive(:in_migration?).and_return(true) end context 'when existing indexes are referred to without an explicit name' do it 'registers an offense' do - expect_offense(<<~RUBY) + expect_offense(<<~RUBY, msg: 'migration methods that refer to existing indexes must do so by name') class TestReferToIndexByName < ActiveRecord::Migration[6.0] DOWNTIME = false @@ -30,22 +27,22 @@ RSpec.describe RuboCop::Cop::Migration::ReferToIndexByName do end if index_exists? :test_indexes, :column2 - ^^^^^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^^^^^ %{msg} remove_index :test_indexes, :column2 - ^^^^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^^^^ %{msg} end remove_index :test_indexes, column: column3 - ^^^^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^^^^ %{msg} remove_index :test_indexes, name: 'index_name_4' end def down if index_exists? :test_indexes, :column4, using: :gin, opclass: :gin_trgm_ops - ^^^^^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^^^^^ %{msg} remove_concurrent_index :test_indexes, :column4, using: :gin, opclass: :gin_trgm_ops - ^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG} + ^^^^^^^^^^^^^^^^^^^^^^^ %{msg} end if index_exists? :test_indexes, :column3, unique: true, name: 'index_name_3', where: 'column3 = 10' @@ -54,13 +51,11 @@ RSpec.describe RuboCop::Cop::Migration::ReferToIndexByName do end end RUBY - - expect(cop.offenses.map(&:cop_name)).to all(eq("Migration/#{described_class.name.demodulize}")) end end end - context 'outside migration' do + context 'when outside migration' do before do allow(cop).to receive(:in_migration?).and_return(false) end diff --git a/spec/rubocop/cop/migration/remove_column_spec.rb b/spec/rubocop/cop/migration/remove_column_spec.rb index 4768093b10d..f72a5b048d5 100644 --- a/spec/rubocop/cop/migration/remove_column_spec.rb +++ b/spec/rubocop/cop/migration/remove_column_spec.rb @@ -1,67 +1,58 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/remove_column' RSpec.describe RuboCop::Cop::Migration::RemoveColumn do - include CopHelper - subject(:cop) { described_class.new } def source(meth = 'change') "def #{meth}; remove_column :table, :column; end" end - context 'in a regular migration' do + context 'when in a regular migration' do before do allow(cop).to receive(:in_migration?).and_return(true) allow(cop).to receive(:in_post_deployment_migration?).and_return(false) end it 'registers an offense when remove_column is used in the change method' do - inspect_source(source('change')) - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([1]) - end + expect_offense(<<~RUBY) + def change + remove_column :table, :column + ^^^^^^^^^^^^^ `remove_column` must only be used in post-deployment migrations + end + RUBY end it 'registers an offense when remove_column is used in the up method' do - inspect_source(source('up')) - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([1]) - end + expect_offense(<<~RUBY) + def up + remove_column :table, :column + ^^^^^^^^^^^^^ `remove_column` must only be used in post-deployment migrations + end + RUBY end it 'registers no offense when remove_column is used in the down method' do - inspect_source(source('down')) - - expect(cop.offenses.size).to eq(0) + expect_no_offenses(source('down')) end end - context 'in a post-deployment migration' do + context 'when in a post-deployment migration' do before do allow(cop).to receive(:in_migration?).and_return(true) allow(cop).to receive(:in_post_deployment_migration?).and_return(true) end it 'registers no offense' do - inspect_source(source) - - expect(cop.offenses.size).to eq(0) + expect_no_offenses(source) end end - context 'outside of a migration' do + context 'when outside of a migration' do it 'registers no offense' do - inspect_source(source) - - expect(cop.offenses.size).to eq(0) + expect_no_offenses(source) end end end diff --git a/spec/rubocop/cop/migration/remove_concurrent_index_spec.rb b/spec/rubocop/cop/migration/remove_concurrent_index_spec.rb index 8da368d588c..10ca0353b0f 100644 --- a/spec/rubocop/cop/migration/remove_concurrent_index_spec.rb +++ b/spec/rubocop/cop/migration/remove_concurrent_index_spec.rb @@ -1,12 +1,9 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/remove_concurrent_index' RSpec.describe RuboCop::Cop::Migration::RemoveConcurrentIndex do - include CopHelper - subject(:cop) { described_class.new } context 'in migration' do @@ -15,26 +12,22 @@ RSpec.describe RuboCop::Cop::Migration::RemoveConcurrentIndex do end it 'registers an offense when remove_concurrent_index is used inside a change method' do - inspect_source('def change; remove_concurrent_index :table, :column; end') - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([1]) - end + expect_offense(<<~RUBY) + def change + ^^^^^^ `remove_concurrent_index` is not reversible [...] + remove_concurrent_index :table, :column + end + RUBY end it 'registers no offense when remove_concurrent_index is used inside an up method' do - inspect_source('def up; remove_concurrent_index :table, :column; end') - - expect(cop.offenses.size).to eq(0) + expect_no_offenses('def up; remove_concurrent_index :table, :column; end') end end context 'outside of migration' do it 'registers no offense' do - inspect_source('def change; remove_concurrent_index :table, :column; end') - - expect(cop.offenses.size).to eq(0) + expect_no_offenses('def change; remove_concurrent_index :table, :column; end') end end end diff --git a/spec/rubocop/cop/migration/remove_index_spec.rb b/spec/rubocop/cop/migration/remove_index_spec.rb index 274c907ac41..5d1ffef2589 100644 --- a/spec/rubocop/cop/migration/remove_index_spec.rb +++ b/spec/rubocop/cop/migration/remove_index_spec.rb @@ -1,34 +1,29 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/remove_index' RSpec.describe RuboCop::Cop::Migration::RemoveIndex do - include CopHelper - subject(:cop) { described_class.new } - context 'in migration' do + context 'when in migration' do before do allow(cop).to receive(:in_migration?).and_return(true) end it 'registers an offense when remove_index is used' do - inspect_source('def change; remove_index :table, :column; end') - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([1]) - end + expect_offense(<<~RUBY) + def change + remove_index :table, :column + ^^^^^^^^^^^^ `remove_index` requires downtime, use `remove_concurrent_index` instead + end + RUBY end end - context 'outside of migration' do + context 'when outside of migration' do it 'registers no offense' do - inspect_source('def change; remove_index :table, :column; end') - - expect(cop.offenses.size).to eq(0) + expect_no_offenses('def change; remove_index :table, :column; end') end end end diff --git a/spec/rubocop/cop/migration/safer_boolean_column_spec.rb b/spec/rubocop/cop/migration/safer_boolean_column_spec.rb index aa7bb58ab45..cf9bdbeef91 100644 --- a/spec/rubocop/cop/migration/safer_boolean_column_spec.rb +++ b/spec/rubocop/cop/migration/safer_boolean_column_spec.rb @@ -1,12 +1,9 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/safer_boolean_column' RSpec.describe RuboCop::Cop::Migration::SaferBooleanColumn do - include CopHelper - subject(:cop) { described_class.new } context 'in migration' do @@ -31,11 +28,10 @@ RSpec.describe RuboCop::Cop::Migration::SaferBooleanColumn do sources_and_offense.each do |source, offense| context "given the source \"#{source}\"" do it "registers the offense matching \"#{offense}\"" do - inspect_source(source) - - aggregate_failures do - expect(cop.offenses.first.message).to match(offense) - end + expect_offense(<<~RUBY, node: source, msg: offense) + %{node} + ^{node} Boolean columns on the `#{table}` table %{msg}.[...] + RUBY end end end @@ -48,11 +44,7 @@ RSpec.describe RuboCop::Cop::Migration::SaferBooleanColumn do inoffensive_sources.each do |source| context "given the source \"#{source}\"" do it "registers no offense" do - inspect_source(source) - - aggregate_failures do - expect(cop.offenses).to be_empty - end + expect_no_offenses(source) end end end @@ -60,25 +52,19 @@ RSpec.describe RuboCop::Cop::Migration::SaferBooleanColumn do end it 'registers no offense for tables not listed in SMALL_TABLES' do - inspect_source("add_column :large_table, :column, :boolean") - - expect(cop.offenses).to be_empty + expect_no_offenses("add_column :large_table, :column, :boolean") end it 'registers no offense for non-boolean columns' do table = described_class::SMALL_TABLES.sample - inspect_source("add_column :#{table}, :column, :string") - - expect(cop.offenses).to be_empty + expect_no_offenses("add_column :#{table}, :column, :string") end end context 'outside of migration' do it 'registers no offense' do table = described_class::SMALL_TABLES.sample - inspect_source("add_column :#{table}, :column, :boolean") - - expect(cop.offenses).to be_empty + expect_no_offenses("add_column :#{table}, :column, :boolean") end end end diff --git a/spec/rubocop/cop/migration/schedule_async_spec.rb b/spec/rubocop/cop/migration/schedule_async_spec.rb index a7246dfa73a..b89acb6db41 100644 --- a/spec/rubocop/cop/migration/schedule_async_spec.rb +++ b/spec/rubocop/cop/migration/schedule_async_spec.rb @@ -2,14 +2,9 @@ require 'fast_spec_helper' -require 'rubocop' -require 'rubocop/rspec/support' - require_relative '../../../../rubocop/cop/migration/schedule_async' RSpec.describe RuboCop::Cop::Migration::ScheduleAsync do - include CopHelper - let(:cop) { described_class.new } let(:source) do <<~SOURCE @@ -21,9 +16,7 @@ RSpec.describe RuboCop::Cop::Migration::ScheduleAsync do shared_examples 'a disabled cop' do it 'does not register any offenses' do - inspect_source(source) - - expect(cop.offenses).to be_empty + expect_no_offenses(source) end end @@ -50,101 +43,73 @@ RSpec.describe RuboCop::Cop::Migration::ScheduleAsync do end context 'BackgroundMigrationWorker.perform_async' do - it 'adds an offence when calling `BackgroundMigrationWorker.peform_async`' do - inspect_source(source) - - expect(cop.offenses.size).to eq(1) - end - - it 'autocorrects to the right version' do - correct_source = <<~CORRECT - def up - migrate_async(ClazzName, "Bar", "Baz") - end - CORRECT + it 'adds an offense when calling `BackgroundMigrationWorker.peform_async` and corrects', :aggregate_failures do + expect_offense(<<~RUBY) + def up + BackgroundMigrationWorker.perform_async(ClazzName, "Bar", "Baz") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't call [...] + end + RUBY - expect(autocorrect_source(source)).to eq(correct_source) + expect_correction(<<~RUBY) + def up + migrate_async(ClazzName, "Bar", "Baz") + end + RUBY end end context 'BackgroundMigrationWorker.perform_in' do - let(:source) do - <<~SOURCE + it 'adds an offense and corrects', :aggregate_failures do + expect_offense(<<~RUBY) def up BackgroundMigrationWorker + ^^^^^^^^^^^^^^^^^^^^^^^^^ Don't call [...] .perform_in(delay, ClazzName, "Bar", "Baz") end - SOURCE - end - - it 'adds an offence' do - inspect_source(source) + RUBY - expect(cop.offenses.size).to eq(1) - end - - it 'autocorrects to the right version' do - correct_source = <<~CORRECT + expect_correction(<<~RUBY) def up migrate_in(delay, ClazzName, "Bar", "Baz") end - CORRECT - - expect(autocorrect_source(source)).to eq(correct_source) + RUBY end end context 'BackgroundMigrationWorker.bulk_perform_async' do - let(:source) do - <<~SOURCE + it 'adds an offense and corrects', :aggregate_failures do + expect_offense(<<~RUBY) def up BackgroundMigrationWorker + ^^^^^^^^^^^^^^^^^^^^^^^^^ Don't call [...] .bulk_perform_async(jobs) end - SOURCE - end - - it 'adds an offence' do - inspect_source(source) - - expect(cop.offenses.size).to eq(1) - end + RUBY - it 'autocorrects to the right version' do - correct_source = <<~CORRECT + expect_correction(<<~RUBY) def up bulk_migrate_async(jobs) end - CORRECT - - expect(autocorrect_source(source)).to eq(correct_source) + RUBY end end context 'BackgroundMigrationWorker.bulk_perform_in' do - let(:source) do - <<~SOURCE + it 'adds an offense and corrects', :aggregate_failures do + expect_offense(<<~RUBY) def up BackgroundMigrationWorker + ^^^^^^^^^^^^^^^^^^^^^^^^^ Don't call [...] .bulk_perform_in(5.minutes, jobs) end - SOURCE - end - - it 'adds an offence' do - inspect_source(source) + RUBY - expect(cop.offenses.size).to eq(1) - end - - it 'autocorrects to the right version' do - correct_source = <<~CORRECT + expect_correction(<<~RUBY) def up bulk_migrate_in(5.minutes, jobs) end - CORRECT - - expect(autocorrect_source(source)).to eq(correct_source) + RUBY end end end diff --git a/spec/rubocop/cop/migration/timestamps_spec.rb b/spec/rubocop/cop/migration/timestamps_spec.rb index 2f4154907d2..91bb5c1b05b 100644 --- a/spec/rubocop/cop/migration/timestamps_spec.rb +++ b/spec/rubocop/cop/migration/timestamps_spec.rb @@ -1,12 +1,9 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/timestamps' RSpec.describe RuboCop::Cop::Migration::Timestamps do - include CopHelper - subject(:cop) { described_class.new } let(:migration_with_timestamps) do @@ -62,38 +59,36 @@ RSpec.describe RuboCop::Cop::Migration::Timestamps do end it 'registers an offense when the "timestamps" method is used' do - inspect_source(migration_with_timestamps) - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([8]) - end + expect_offense(<<~RUBY) + class Users < ActiveRecord::Migration[4.2] + DOWNTIME = false + + def change + create_table :users do |t| + t.string :username, null: false + t.timestamps null: true + ^^^^^^^^^^ Do not use `timestamps`, use `timestamps_with_timezone` instead + t.string :password + end + end + end + RUBY end it 'does not register an offense when the "timestamps" method is not used' do - inspect_source(migration_without_timestamps) - - aggregate_failures do - expect(cop.offenses.size).to eq(0) - end + expect_no_offenses(migration_without_timestamps) end it 'does not register an offense when the "timestamps_with_timezone" method is used' do - inspect_source(migration_with_timestamps_with_timezone) - - aggregate_failures do - expect(cop.offenses.size).to eq(0) - end + expect_no_offenses(migration_with_timestamps_with_timezone) end end context 'outside of migration' do - it 'registers no offense' do - inspect_source(migration_with_timestamps) - inspect_source(migration_without_timestamps) - inspect_source(migration_with_timestamps_with_timezone) - - expect(cop.offenses.size).to eq(0) + it 'registers no offense', :aggregate_failures do + expect_no_offenses(migration_with_timestamps) + expect_no_offenses(migration_without_timestamps) + expect_no_offenses(migration_with_timestamps_with_timezone) end end end diff --git a/spec/rubocop/cop/migration/update_column_in_batches_spec.rb b/spec/rubocop/cop/migration/update_column_in_batches_spec.rb index 8049cba12d0..a12ae94c22b 100644 --- a/spec/rubocop/cop/migration/update_column_in_batches_spec.rb +++ b/spec/rubocop/cop/migration/update_column_in_batches_spec.rb @@ -2,9 +2,6 @@ require 'fast_spec_helper' -require 'rubocop' -require 'rubocop/rspec/support' - require_relative '../../../../rubocop/cop/migration/update_column_in_batches' RSpec.describe RuboCop::Cop::Migration::UpdateColumnInBatches do @@ -31,9 +28,7 @@ RSpec.describe RuboCop::Cop::Migration::UpdateColumnInBatches do context 'outside of a migration' do it 'does not register any offenses' do - inspect_source(migration_code) - - expect(cop.offenses).to be_empty + expect_no_offenses(migration_code) end end @@ -53,14 +48,14 @@ RSpec.describe RuboCop::Cop::Migration::UpdateColumnInBatches do let(:relative_spec_filepath) { Pathname.new(spec_filepath).relative_path_from(tmp_rails_root) } it 'registers an offense when using update_column_in_batches' do - inspect_source(migration_code, @migration_file) - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([2]) - expect(cop.offenses.first.message) - .to include("`#{relative_spec_filepath}`") - end + expect_offense(<<~RUBY, @migration_file) + def up + update_column_in_batches(:projects, :name, "foo") do |table, query| + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Migration running `update_column_in_batches` [...] + query.where(table[:name].eq(nil)) + end + end + RUBY end end @@ -76,20 +71,18 @@ RSpec.describe RuboCop::Cop::Migration::UpdateColumnInBatches do end it 'does not register any offenses' do - inspect_source(migration_code, @migration_file) - - expect(cop.offenses).to be_empty + expect_no_offenses(migration_code) end end - context 'in a migration' do + context 'when in migration' do let(:migration_filepath) { File.join(tmp_rails_root, 'db', 'migrate', '20121220064453_my_super_migration.rb') } it_behaves_like 'a migration file with no spec file' it_behaves_like 'a migration file with a spec file' end - context 'in a post migration' do + context 'when in a post migration' do let(:migration_filepath) { File.join(tmp_rails_root, 'db', 'post_migrate', '20121220064453_my_super_migration.rb') } it_behaves_like 'a migration file with no spec file' @@ -99,14 +92,14 @@ RSpec.describe RuboCop::Cop::Migration::UpdateColumnInBatches do context 'EE migrations' do let(:spec_filepath) { File.join(tmp_rails_root, 'ee', 'spec', 'migrations', 'my_super_migration_spec.rb') } - context 'in a migration' do + context 'when in a migration' do let(:migration_filepath) { File.join(tmp_rails_root, 'ee', 'db', 'migrate', '20121220064453_my_super_migration.rb') } it_behaves_like 'a migration file with no spec file' it_behaves_like 'a migration file with a spec file' end - context 'in a post migration' do + context 'when in a post migration' do let(:migration_filepath) { File.join(tmp_rails_root, 'ee', 'db', 'post_migrate', '20121220064453_my_super_migration.rb') } it_behaves_like 'a migration file with no spec file' diff --git a/spec/rubocop/cop/migration/with_lock_retries_disallowed_method_spec.rb b/spec/rubocop/cop/migration/with_lock_retries_disallowed_method_spec.rb index 814d87ea24b..298ca273256 100644 --- a/spec/rubocop/cop/migration/with_lock_retries_disallowed_method_spec.rb +++ b/spec/rubocop/cop/migration/with_lock_retries_disallowed_method_spec.rb @@ -1,64 +1,58 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/with_lock_retries_disallowed_method' RSpec.describe RuboCop::Cop::Migration::WithLockRetriesDisallowedMethod do - include CopHelper - subject(:cop) { described_class.new } - context 'in migration' do + context 'when in migration' do before do allow(cop).to receive(:in_migration?).and_return(true) end it 'registers an offense when `with_lock_retries` block has disallowed method' do - inspect_source('def change; with_lock_retries { disallowed_method }; end') - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([1]) - end + expect_offense(<<~RUBY) + def change + with_lock_retries { disallowed_method } + ^^^^^^^^^^^^^^^^^ The method is not allowed [...] + end + RUBY end it 'registers an offense when `with_lock_retries` block has disallowed methods' do - source = <<~HEREDOC - def change - with_lock_retries do - disallowed_method + expect_offense(<<~RUBY) + def change + with_lock_retries do + disallowed_method + ^^^^^^^^^^^^^^^^^ The method is not allowed [...] - create_table do |t| - t.text :text - end + create_table do |t| + t.text :text + end - other_disallowed_method + other_disallowed_method + ^^^^^^^^^^^^^^^^^^^^^^^ The method is not allowed [...] - add_column :users, :name + add_column :users, :name + end end - end - HEREDOC - - inspect_source(source) - - aggregate_failures do - expect(cop.offenses.size).to eq(2) - expect(cop.offenses.map(&:line)).to eq([3, 9]) - end + RUBY end it 'registers no offense when `with_lock_retries` has only allowed method' do - inspect_source('def up; with_lock_retries { add_foreign_key :foo, :bar }; end') - - expect(cop.offenses.size).to eq(0) + expect_no_offenses(<<~RUBY) + def up + with_lock_retries { add_foreign_key :foo, :bar } + end + RUBY end describe 'for `add_foreign_key`' do it 'registers an offense when more than two FKs are added' do message = described_class::MSG_ONLY_ONE_FK_ALLOWED - expect_offense <<~RUBY + expect_offense(<<~RUBY) with_lock_retries do add_foreign_key :imports, :projects, column: :project_id, on_delete: :cascade ^^^^^^^^^^^^^^^ #{message} @@ -71,11 +65,13 @@ RSpec.describe RuboCop::Cop::Migration::WithLockRetriesDisallowedMethod do end end - context 'outside of migration' do + context 'when outside of migration' do it 'registers no offense' do - inspect_source('def change; with_lock_retries { disallowed_method }; end') - - expect(cop.offenses.size).to eq(0) + expect_no_offenses(<<~RUBY) + def change + with_lock_retries { disallowed_method } + end + RUBY end end end diff --git a/spec/rubocop/cop/migration/with_lock_retries_with_change_spec.rb b/spec/rubocop/cop/migration/with_lock_retries_with_change_spec.rb index f0be14c8ee9..f2e84a8697c 100644 --- a/spec/rubocop/cop/migration/with_lock_retries_with_change_spec.rb +++ b/spec/rubocop/cop/migration/with_lock_retries_with_change_spec.rb @@ -1,40 +1,41 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/migration/with_lock_retries_with_change' RSpec.describe RuboCop::Cop::Migration::WithLockRetriesWithChange do - include CopHelper - subject(:cop) { described_class.new } - context 'in migration' do + context 'when in migration' do before do allow(cop).to receive(:in_migration?).and_return(true) end it 'registers an offense when `with_lock_retries` is used inside a `change` method' do - inspect_source('def change; with_lock_retries {}; end') - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([1]) - end + expect_offense(<<~RUBY) + def change + ^^^^^^ `with_lock_retries` cannot be used within `change` [...] + with_lock_retries {} + end + RUBY end it 'registers no offense when `with_lock_retries` is used inside an `up` method' do - inspect_source('def up; with_lock_retries {}; end') - - expect(cop.offenses.size).to eq(0) + expect_no_offenses(<<~RUBY) + def up + with_lock_retries {} + end + RUBY end end - context 'outside of migration' do + context 'when outside of migration' do it 'registers no offense' do - inspect_source('def change; with_lock_retries {}; end') - - expect(cop.offenses.size).to eq(0) + expect_no_offenses(<<~RUBY) + def change + with_lock_retries {} + end + RUBY end end end diff --git a/spec/rubocop/cop/performance/ar_count_each_spec.rb b/spec/rubocop/cop/performance/ar_count_each_spec.rb index 402e3e93147..fa7a1aba426 100644 --- a/spec/rubocop/cop/performance/ar_count_each_spec.rb +++ b/spec/rubocop/cop/performance/ar_count_each_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/performance/ar_count_each.rb' RSpec.describe RuboCop::Cop::Performance::ARCountEach do diff --git a/spec/rubocop/cop/performance/ar_exists_and_present_blank_spec.rb b/spec/rubocop/cop/performance/ar_exists_and_present_blank_spec.rb index 8497ff0e909..127c858a549 100644 --- a/spec/rubocop/cop/performance/ar_exists_and_present_blank_spec.rb +++ b/spec/rubocop/cop/performance/ar_exists_and_present_blank_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/performance/ar_exists_and_present_blank.rb' RSpec.describe RuboCop::Cop::Performance::ARExistsAndPresentBlank do diff --git a/spec/rubocop/cop/performance/readlines_each_spec.rb b/spec/rubocop/cop/performance/readlines_each_spec.rb index 5a30107722a..0a8b168ce5d 100644 --- a/spec/rubocop/cop/performance/readlines_each_spec.rb +++ b/spec/rubocop/cop/performance/readlines_each_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/performance/readlines_each' RSpec.describe RuboCop::Cop::Performance::ReadlinesEach do diff --git a/spec/rubocop/cop/prefer_class_methods_over_module_spec.rb b/spec/rubocop/cop/prefer_class_methods_over_module_spec.rb index dc665f9dd25..1261ca7891c 100644 --- a/spec/rubocop/cop/prefer_class_methods_over_module_spec.rb +++ b/spec/rubocop/cop/prefer_class_methods_over_module_spec.rb @@ -1,16 +1,12 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' -require 'rubocop/rspec/support' require_relative '../../../rubocop/cop/prefer_class_methods_over_module' RSpec.describe RuboCop::Cop::PreferClassMethodsOverModule do - include CopHelper - subject(:cop) { described_class.new } - it 'flags violation when using module ClassMethods' do + it 'flags violation when using module ClassMethods and corrects', :aggregate_failures do expect_offense(<<~RUBY) module Foo extend ActiveSupport::Concern @@ -22,6 +18,17 @@ RSpec.describe RuboCop::Cop::PreferClassMethodsOverModule do end end RUBY + + expect_correction(<<~RUBY) + module Foo + extend ActiveSupport::Concern + + class_methods do + def a_class_method + end + end + end + RUBY end it "doesn't flag violation when using class_methods" do @@ -69,30 +76,4 @@ RSpec.describe RuboCop::Cop::PreferClassMethodsOverModule do end RUBY end - - it 'autocorrects ClassMethods into class_methods' do - source = <<~RUBY - module Foo - extend ActiveSupport::Concern - - module ClassMethods - def a_class_method - end - end - end - RUBY - autocorrected = autocorrect_source(source) - - expected_source = <<~RUBY - module Foo - extend ActiveSupport::Concern - - class_methods do - def a_class_method - end - end - end - RUBY - expect(autocorrected).to eq(expected_source) - end end diff --git a/spec/rubocop/cop/project_path_helper_spec.rb b/spec/rubocop/cop/project_path_helper_spec.rb index 16782802a27..b3c920f9d25 100644 --- a/spec/rubocop/cop/project_path_helper_spec.rb +++ b/spec/rubocop/cop/project_path_helper_spec.rb @@ -2,7 +2,6 @@ require 'fast_spec_helper' -require 'rubocop' require_relative '../../../rubocop/cop/project_path_helper' RSpec.describe RuboCop::Cop::ProjectPathHelper do diff --git a/spec/rubocop/cop/put_group_routes_under_scope_spec.rb b/spec/rubocop/cop/put_group_routes_under_scope_spec.rb index 46b50d7690b..366fc4b5657 100644 --- a/spec/rubocop/cop/put_group_routes_under_scope_spec.rb +++ b/spec/rubocop/cop/put_group_routes_under_scope_spec.rb @@ -1,12 +1,9 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../rubocop/cop/put_group_routes_under_scope' RSpec.describe RuboCop::Cop::PutGroupRoutesUnderScope do - include CopHelper - subject(:cop) { described_class.new } %w[resource resources get post put patch delete].each do |route_method| @@ -15,12 +12,12 @@ RSpec.describe RuboCop::Cop::PutGroupRoutesUnderScope do marker = '^' * offense.size expect_offense(<<~PATTERN) - scope(path: 'groups/*group_id/-', module: :groups) do - resource :issues - end + scope(path: 'groups/*group_id/-', module: :groups) do + resource :issues + end - #{offense} - #{marker} Put new group routes under /-/ scope + #{offense} + #{marker} Put new group routes under /-/ scope PATTERN end end diff --git a/spec/rubocop/cop/put_project_routes_under_scope_spec.rb b/spec/rubocop/cop/put_project_routes_under_scope_spec.rb index eb783d22129..9d226db09ef 100644 --- a/spec/rubocop/cop/put_project_routes_under_scope_spec.rb +++ b/spec/rubocop/cop/put_project_routes_under_scope_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../rubocop/cop/put_project_routes_under_scope' RSpec.describe RuboCop::Cop::PutProjectRoutesUnderScope do diff --git a/spec/rubocop/cop/qa/ambiguous_page_object_name_spec.rb b/spec/rubocop/cop/qa/ambiguous_page_object_name_spec.rb index 9332ab4186e..9335b8d01ee 100644 --- a/spec/rubocop/cop/qa/ambiguous_page_object_name_spec.rb +++ b/spec/rubocop/cop/qa/ambiguous_page_object_name_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/qa/ambiguous_page_object_name' diff --git a/spec/rubocop/cop/qa/element_with_pattern_spec.rb b/spec/rubocop/cop/qa/element_with_pattern_spec.rb index 28c351ccf1e..d3e79525c62 100644 --- a/spec/rubocop/cop/qa/element_with_pattern_spec.rb +++ b/spec/rubocop/cop/qa/element_with_pattern_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/qa/element_with_pattern' diff --git a/spec/rubocop/cop/rspec/be_success_matcher_spec.rb b/spec/rubocop/cop/rspec/be_success_matcher_spec.rb index 050f0396fac..678e62048b8 100644 --- a/spec/rubocop/cop/rspec/be_success_matcher_spec.rb +++ b/spec/rubocop/cop/rspec/be_success_matcher_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/rspec/be_success_matcher' RSpec.describe RuboCop::Cop::RSpec::BeSuccessMatcher do diff --git a/spec/rubocop/cop/rspec/env_assignment_spec.rb b/spec/rubocop/cop/rspec/env_assignment_spec.rb index cc132d1532a..da6bb2fa2fb 100644 --- a/spec/rubocop/cop/rspec/env_assignment_spec.rb +++ b/spec/rubocop/cop/rspec/env_assignment_spec.rb @@ -2,7 +2,6 @@ require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/rspec/env_assignment' RSpec.describe RuboCop::Cop::RSpec::EnvAssignment do diff --git a/spec/rubocop/cop/rspec/expect_gitlab_tracking_spec.rb b/spec/rubocop/cop/rspec/expect_gitlab_tracking_spec.rb index d1ce8d01e0b..e36feecdd66 100644 --- a/spec/rubocop/cop/rspec/expect_gitlab_tracking_spec.rb +++ b/spec/rubocop/cop/rspec/expect_gitlab_tracking_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/rspec/expect_gitlab_tracking' RSpec.describe RuboCop::Cop::RSpec::ExpectGitlabTracking do diff --git a/spec/rubocop/cop/rspec/factories_in_migration_specs_spec.rb b/spec/rubocop/cop/rspec/factories_in_migration_specs_spec.rb index 8beec53375e..74c1521fa0e 100644 --- a/spec/rubocop/cop/rspec/factories_in_migration_specs_spec.rb +++ b/spec/rubocop/cop/rspec/factories_in_migration_specs_spec.rb @@ -2,7 +2,6 @@ require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/rspec/factories_in_migration_specs' RSpec.describe RuboCop::Cop::RSpec::FactoriesInMigrationSpecs do diff --git a/spec/rubocop/cop/rspec/factory_bot/inline_association_spec.rb b/spec/rubocop/cop/rspec/factory_bot/inline_association_spec.rb index 0e6af71ea3e..194e2436ff2 100644 --- a/spec/rubocop/cop/rspec/factory_bot/inline_association_spec.rb +++ b/spec/rubocop/cop/rspec/factory_bot/inline_association_spec.rb @@ -2,7 +2,6 @@ require 'fast_spec_helper' require 'rspec-parameterized' -require 'rubocop' require_relative '../../../../../rubocop/cop/rspec/factory_bot/inline_association' diff --git a/spec/rubocop/cop/rspec/have_gitlab_http_status_spec.rb b/spec/rubocop/cop/rspec/have_gitlab_http_status_spec.rb index c2d97c8992a..9bdbe145f4c 100644 --- a/spec/rubocop/cop/rspec/have_gitlab_http_status_spec.rb +++ b/spec/rubocop/cop/rspec/have_gitlab_http_status_spec.rb @@ -3,7 +3,6 @@ require 'fast_spec_helper' require 'rspec-parameterized' -require 'rubocop' require_relative '../../../../rubocop/cop/rspec/have_gitlab_http_status' RSpec.describe RuboCop::Cop::RSpec::HaveGitlabHttpStatus do diff --git a/spec/rubocop/cop/rspec/modify_sidekiq_middleware_spec.rb b/spec/rubocop/cop/rspec/modify_sidekiq_middleware_spec.rb index ffabbae90dc..7a2b7c92bd1 100644 --- a/spec/rubocop/cop/rspec/modify_sidekiq_middleware_spec.rb +++ b/spec/rubocop/cop/rspec/modify_sidekiq_middleware_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/rspec/modify_sidekiq_middleware' RSpec.describe RuboCop::Cop::RSpec::ModifySidekiqMiddleware do diff --git a/spec/rubocop/cop/rspec/timecop_freeze_spec.rb b/spec/rubocop/cop/rspec/timecop_freeze_spec.rb index 939623f8299..b8d16d58d9e 100644 --- a/spec/rubocop/cop/rspec/timecop_freeze_spec.rb +++ b/spec/rubocop/cop/rspec/timecop_freeze_spec.rb @@ -2,7 +2,6 @@ require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/rspec/timecop_freeze' RSpec.describe RuboCop::Cop::RSpec::TimecopFreeze do diff --git a/spec/rubocop/cop/rspec/timecop_travel_spec.rb b/spec/rubocop/cop/rspec/timecop_travel_spec.rb index 476e45e69a6..16e09fb8c45 100644 --- a/spec/rubocop/cop/rspec/timecop_travel_spec.rb +++ b/spec/rubocop/cop/rspec/timecop_travel_spec.rb @@ -2,7 +2,6 @@ require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/rspec/timecop_travel' RSpec.describe RuboCop::Cop::RSpec::TimecopTravel do diff --git a/spec/rubocop/cop/rspec/top_level_describe_path_spec.rb b/spec/rubocop/cop/rspec/top_level_describe_path_spec.rb index 23531cd0201..78e6bec51d4 100644 --- a/spec/rubocop/cop/rspec/top_level_describe_path_spec.rb +++ b/spec/rubocop/cop/rspec/top_level_describe_path_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/rspec/top_level_describe_path' RSpec.describe RuboCop::Cop::RSpec::TopLevelDescribePath do diff --git a/spec/rubocop/cop/ruby_interpolation_in_translation_spec.rb b/spec/rubocop/cop/ruby_interpolation_in_translation_spec.rb index cacf0a1b67d..c21999be917 100644 --- a/spec/rubocop/cop/ruby_interpolation_in_translation_spec.rb +++ b/spec/rubocop/cop/ruby_interpolation_in_translation_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../rubocop/cop/ruby_interpolation_in_translation' diff --git a/spec/rubocop/cop/safe_params_spec.rb b/spec/rubocop/cop/safe_params_spec.rb index 62f8e542d86..9a064b93b16 100644 --- a/spec/rubocop/cop/safe_params_spec.rb +++ b/spec/rubocop/cop/safe_params_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../rubocop/cop/safe_params' RSpec.describe RuboCop::Cop::SafeParams do diff --git a/spec/rubocop/cop/scalability/bulk_perform_with_context_spec.rb b/spec/rubocop/cop/scalability/bulk_perform_with_context_spec.rb index a19ddf9dbe6..01afaf3acb6 100644 --- a/spec/rubocop/cop/scalability/bulk_perform_with_context_spec.rb +++ b/spec/rubocop/cop/scalability/bulk_perform_with_context_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/scalability/bulk_perform_with_context' RSpec.describe RuboCop::Cop::Scalability::BulkPerformWithContext do diff --git a/spec/rubocop/cop/scalability/cron_worker_context_spec.rb b/spec/rubocop/cop/scalability/cron_worker_context_spec.rb index 11b2b82d2f5..28db12fd075 100644 --- a/spec/rubocop/cop/scalability/cron_worker_context_spec.rb +++ b/spec/rubocop/cop/scalability/cron_worker_context_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/scalability/cron_worker_context' RSpec.describe RuboCop::Cop::Scalability::CronWorkerContext do diff --git a/spec/rubocop/cop/scalability/file_uploads_spec.rb b/spec/rubocop/cop/scalability/file_uploads_spec.rb index bda5c056b03..ca25b0246f0 100644 --- a/spec/rubocop/cop/scalability/file_uploads_spec.rb +++ b/spec/rubocop/cop/scalability/file_uploads_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/scalability/file_uploads' RSpec.describe RuboCop::Cop::Scalability::FileUploads do diff --git a/spec/rubocop/cop/scalability/idempotent_worker_spec.rb b/spec/rubocop/cop/scalability/idempotent_worker_spec.rb index 729f2613697..53c0c06f6c9 100644 --- a/spec/rubocop/cop/scalability/idempotent_worker_spec.rb +++ b/spec/rubocop/cop/scalability/idempotent_worker_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/scalability/idempotent_worker' RSpec.describe RuboCop::Cop::Scalability::IdempotentWorker do diff --git a/spec/rubocop/cop/sidekiq_options_queue_spec.rb b/spec/rubocop/cop/sidekiq_options_queue_spec.rb index 306cbcf62b5..346a8d82475 100644 --- a/spec/rubocop/cop/sidekiq_options_queue_spec.rb +++ b/spec/rubocop/cop/sidekiq_options_queue_spec.rb @@ -2,29 +2,19 @@ require 'fast_spec_helper' -require 'rubocop' -require 'rubocop/rspec/support' - require_relative '../../../rubocop/cop/sidekiq_options_queue' RSpec.describe RuboCop::Cop::SidekiqOptionsQueue do - include CopHelper - subject(:cop) { described_class.new } it 'registers an offense when `sidekiq_options` is used with the `queue` option' do - inspect_source('sidekiq_options queue: "some_queue"') - - aggregate_failures do - expect(cop.offenses.size).to eq(1) - expect(cop.offenses.map(&:line)).to eq([1]) - expect(cop.highlights).to eq(['queue: "some_queue"']) - end + expect_offense(<<~CODE) + sidekiq_options queue: "some_queue" + ^^^^^^^^^^^^^^^^^^^ Do not manually set a queue; `ApplicationWorker` sets one automatically. + CODE end it 'does not register an offense when `sidekiq_options` is used with another option' do - inspect_source('sidekiq_options retry: false') - - expect(cop.offenses).to be_empty + expect_no_offenses('sidekiq_options retry: false') end end diff --git a/spec/rubocop/cop/static_translation_definition_spec.rb b/spec/rubocop/cop/static_translation_definition_spec.rb index 8656b07a6e4..b2b04cbcbde 100644 --- a/spec/rubocop/cop/static_translation_definition_spec.rb +++ b/spec/rubocop/cop/static_translation_definition_spec.rb @@ -2,7 +2,6 @@ require 'fast_spec_helper' -require 'rubocop' require 'rspec-parameterized' require_relative '../../../rubocop/cop/static_translation_definition' diff --git a/spec/rubocop/cop/usage_data/distinct_count_by_large_foreign_key_spec.rb b/spec/rubocop/cop/usage_data/distinct_count_by_large_foreign_key_spec.rb index b6711effe9e..f377dfe36d8 100644 --- a/spec/rubocop/cop/usage_data/distinct_count_by_large_foreign_key_spec.rb +++ b/spec/rubocop/cop/usage_data/distinct_count_by_large_foreign_key_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/usage_data/distinct_count_by_large_foreign_key' diff --git a/spec/rubocop/cop/usage_data/large_table_spec.rb b/spec/rubocop/cop/usage_data/large_table_spec.rb index 26bd4e61625..a6b22fd7f0d 100644 --- a/spec/rubocop/cop/usage_data/large_table_spec.rb +++ b/spec/rubocop/cop/usage_data/large_table_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require_relative '../../../../rubocop/cop/usage_data/large_table' diff --git a/spec/rubocop/migration_helpers_spec.rb b/spec/rubocop/migration_helpers_spec.rb index f0be21c9d70..997d4071c29 100644 --- a/spec/rubocop/migration_helpers_spec.rb +++ b/spec/rubocop/migration_helpers_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require 'rspec-parameterized' require_relative '../../rubocop/migration_helpers' diff --git a/spec/rubocop/qa_helpers_spec.rb b/spec/rubocop/qa_helpers_spec.rb index cf6d2f1a845..4b5566609e3 100644 --- a/spec/rubocop/qa_helpers_spec.rb +++ b/spec/rubocop/qa_helpers_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'rubocop' require 'parser/current' require_relative '../../rubocop/qa_helpers' diff --git a/spec/serializers/base_discussion_entity_spec.rb b/spec/serializers/base_discussion_entity_spec.rb index 5f483da4113..334e71d23f4 100644 --- a/spec/serializers/base_discussion_entity_spec.rb +++ b/spec/serializers/base_discussion_entity_spec.rb @@ -66,4 +66,13 @@ RSpec.describe BaseDiscussionEntity do ) end end + + context 'when issues are disabled in a project' do + let(:project) { create(:project, :issues_disabled) } + let(:note) { create(:discussion_note_on_merge_request, project: project) } + + it 'does not show a new issues path' do + expect(entity.as_json[:resolve_with_issue_path]).to be_nil + end + end end diff --git a/spec/serializers/merge_request_user_entity_spec.rb b/spec/serializers/merge_request_user_entity_spec.rb index dcd4ef6acfb..697fa3001e3 100644 --- a/spec/serializers/merge_request_user_entity_spec.rb +++ b/spec/serializers/merge_request_user_entity_spec.rb @@ -3,19 +3,22 @@ require 'spec_helper' RSpec.describe MergeRequestUserEntity do - let(:user) { create(:user) } - let(:project) { create(:project, :repository) } - let(:request) { EntityRequest.new(project: project, current_user: user) } + let_it_be(:user) { create(:user) } + let_it_be(:merge_request) { create(:merge_request) } + let(:request) { EntityRequest.new(project: merge_request.target_project, current_user: user) } let(:entity) do - described_class.new(user, request: request) + described_class.new(user, request: request, merge_request: merge_request) end - context 'as json' do + describe '#as_json' do subject { entity.as_json } it 'exposes needed attributes' do - expect(subject).to include(:id, :name, :username, :state, :avatar_url, :web_url, :can_merge) + is_expected.to include( + :id, :name, :username, :state, :avatar_url, :web_url, + :can_merge, :can_update_merge_request, :reviewed, :approved + ) end context 'when `status` is not preloaded' do @@ -24,6 +27,22 @@ RSpec.describe MergeRequestUserEntity do end end + context 'when the user has not approved the merge-request' do + it 'exposes that the user has not approved the MR' do + expect(subject).to include(approved: false) + end + end + + context 'when the user has approved the merge-request' do + before do + merge_request.approvals.create!(user: user) + end + + it 'exposes that the user has approved the MR' do + expect(subject).to include(approved: true) + end + end + context 'when `status` is preloaded' do before do user.create_status!(availability: :busy) @@ -35,5 +54,27 @@ RSpec.describe MergeRequestUserEntity do expect(subject[:availability]).to eq('busy') end end + + describe 'performance' do + let_it_be(:user_a) { create(:user) } + let_it_be(:user_b) { create(:user) } + let_it_be(:merge_request_b) { create(:merge_request) } + + it 'is linear in the number of merge requests' do + pending "See: https://gitlab.com/gitlab-org/gitlab/-/issues/322549" + baseline = ActiveRecord::QueryRecorder.new do + ent = described_class.new(user_a, request: request, merge_request: merge_request) + ent.as_json + end + + expect do + a = described_class.new(user_a, request: request, merge_request: merge_request_b) + b = described_class.new(user_b, request: request, merge_request: merge_request_b) + + a.as_json + b.as_json + end.not_to exceed_query_limit(baseline) + end + end end end diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb index e0f6ab68034..bcaaa61eb04 100644 --- a/spec/serializers/pipeline_serializer_spec.rb +++ b/spec/serializers/pipeline_serializer_spec.rb @@ -209,6 +209,22 @@ RSpec.describe PipelineSerializer do end end + context 'with scheduled and manual builds' do + let(:ref) { 'feature' } + + before do + create(:ci_build, :scheduled, pipeline: resource.first) + create(:ci_build, :scheduled, pipeline: resource.second) + create(:ci_build, :manual, pipeline: resource.first) + create(:ci_build, :manual, pipeline: resource.second) + end + + it 'sends at most one metadata query for each type of build', :request_store do + # 1 for the existing failed builds and 2 for the added scheduled and manual builds + expect { subject }.not_to exceed_query_limit(1 + 2).for_query /SELECT "ci_builds_metadata".*/ + end + end + def create_pipeline(status) create(:ci_empty_pipeline, project: project, diff --git a/spec/serializers/test_suite_comparer_entity_spec.rb b/spec/serializers/test_suite_comparer_entity_spec.rb index a63f5683779..318d1d3c1e3 100644 --- a/spec/serializers/test_suite_comparer_entity_spec.rb +++ b/spec/serializers/test_suite_comparer_entity_spec.rb @@ -35,6 +35,7 @@ RSpec.describe TestSuiteComparerEntity do end expect(subject[:resolved_failures]).to be_empty expect(subject[:existing_failures]).to be_empty + expect(subject[:suite_errors]).to be_nil end end @@ -56,6 +57,7 @@ RSpec.describe TestSuiteComparerEntity do end expect(subject[:resolved_failures]).to be_empty expect(subject[:existing_failures]).to be_empty + expect(subject[:suite_errors]).to be_nil end end @@ -77,6 +79,7 @@ RSpec.describe TestSuiteComparerEntity do expect(existing_failure[:execution_time]).to eq(test_case_failed.execution_time) expect(existing_failure[:system_output]).to eq(test_case_failed.system_output) end + expect(subject[:suite_errors]).to be_nil end end @@ -98,6 +101,47 @@ RSpec.describe TestSuiteComparerEntity do expect(resolved_failure[:system_output]).to eq(test_case_success.system_output) end expect(subject[:existing_failures]).to be_empty + expect(subject[:suite_errors]).to be_nil + end + end + + context 'when head suite has suite error' do + before do + allow(head_suite).to receive(:suite_error).and_return('some error') + end + + it 'contains suite error for head suite' do + expect(subject[:suite_errors]).to eq( + head: 'some error', + base: nil + ) + end + end + + context 'when base suite has suite error' do + before do + allow(base_suite).to receive(:suite_error).and_return('some error') + end + + it 'contains suite error for head suite' do + expect(subject[:suite_errors]).to eq( + head: nil, + base: 'some error' + ) + end + end + + context 'when base and head suite both have suite errors' do + before do + allow(head_suite).to receive(:suite_error).and_return('head error') + allow(base_suite).to receive(:suite_error).and_return('base error') + end + + it 'contains suite error for head suite' do + expect(subject[:suite_errors]).to eq( + head: 'head error', + base: 'base error' + ) end end end diff --git a/spec/serializers/test_suite_summary_entity_spec.rb b/spec/serializers/test_suite_summary_entity_spec.rb index 864781ccfce..3d43feba910 100644 --- a/spec/serializers/test_suite_summary_entity_spec.rb +++ b/spec/serializers/test_suite_summary_entity_spec.rb @@ -20,5 +20,9 @@ RSpec.describe TestSuiteSummaryEntity do it 'contains the build_ids' do expect(as_json).to include(:build_ids) end + + it 'contains the suite_error' do + expect(as_json).to include(:suite_error) + end end end diff --git a/spec/services/alert_management/create_alert_issue_service_spec.rb b/spec/services/alert_management/create_alert_issue_service_spec.rb index 2834322be7b..695e90ebd92 100644 --- a/spec/services/alert_management/create_alert_issue_service_spec.rb +++ b/spec/services/alert_management/create_alert_issue_service_spec.rb @@ -118,9 +118,36 @@ RSpec.describe AlertManagement::CreateAlertIssueService do context 'when the alert is generic' do let(:alert) { generic_alert } let(:issue) { subject.payload[:issue] } + let(:default_alert_title) { described_class::DEFAULT_ALERT_TITLE } it_behaves_like 'creating an alert issue' it_behaves_like 'setting an issue attributes' + + context 'when alert title matches the default title exactly' do + before do + generic_alert.update!(title: default_alert_title) + end + + it 'updates issue title with the IID' do + execute + + expect(created_issue.title).to eq("New: Incident #{created_issue.iid}") + end + end + + context 'when the alert title contains the default title' do + let(:non_default_alert_title) { "Not #{default_alert_title}" } + + before do + generic_alert.update!(title: non_default_alert_title) + end + + it 'does not change issue title' do + execute + + expect(created_issue.title).to eq(non_default_alert_title) + end + end end context 'when issue cannot be created' do diff --git a/spec/services/alert_management/process_prometheus_alert_service_spec.rb b/spec/services/alert_management/process_prometheus_alert_service_spec.rb index 288a33b71cd..9bd71ea6f64 100644 --- a/spec/services/alert_management/process_prometheus_alert_service_spec.rb +++ b/spec/services/alert_management/process_prometheus_alert_service_spec.rb @@ -11,6 +11,7 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do describe '#execute' do let(:service) { described_class.new(project, payload) } + let(:source) { 'Prometheus' } let(:auto_close_incident) { true } let(:create_issue) { true } let(:send_email) { true } @@ -31,7 +32,7 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do subject(:execute) { service.execute } context 'when alert payload is valid' do - let(:parsed_payload) { Gitlab::AlertManagement::Payload.parse(project, payload, monitoring_tool: 'Prometheus') } + let(:parsed_payload) { Gitlab::AlertManagement::Payload.parse(project, payload, monitoring_tool: source) } let(:fingerprint) { parsed_payload.gitlab_fingerprint } let(:payload) do { @@ -112,9 +113,7 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do it_behaves_like 'Alert Notification Service sends notification email' it_behaves_like 'processes incident issues' - it 'creates a system note corresponding to alert creation' do - expect { subject }.to change(Note, :count).by(1) - end + it_behaves_like 'creates single system note based on the source of the alert' context 'when auto-alert creation is disabled' do let(:create_issue) { false } @@ -158,17 +157,20 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do context 'when Prometheus alert status is resolved' do let(:status) { 'resolved' } - let!(:alert) { create(:alert_management_alert, project: project, fingerprint: fingerprint) } + let!(:alert) { create(:alert_management_alert, project: project, fingerprint: fingerprint, monitoring_tool: source) } context 'when auto_resolve_incident set to true' do context 'when status can be changed' do it_behaves_like 'Alert Notification Service sends notification email' it_behaves_like 'does not process incident issues' - it 'resolves an existing alert' do + it 'resolves an existing alert without error' do + expect(Gitlab::AppLogger).not_to receive(:warn) expect { execute }.to change { alert.reload.resolved? }.to(true) end + it_behaves_like 'creates status-change system note for an auto-resolved alert' + context 'existing issue' do let!(:alert) { create(:alert_management_alert, :with_issue, project: project, fingerprint: fingerprint) } @@ -215,6 +217,8 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do it 'does not resolve an existing alert' do expect { execute }.not_to change { alert.reload.resolved? } end + + it_behaves_like 'creates single system note based on the source of the alert' end context 'when emails are disabled' do diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb index 29b49db42f9..2fd544ab949 100644 --- a/spec/services/boards/issues/list_service_spec.rb +++ b/spec/services/boards/issues/list_service_spec.rb @@ -4,40 +4,41 @@ require 'spec_helper' RSpec.describe Boards::Issues::ListService do describe '#execute' do - context 'when parent is a project' do - let(:user) { create(:user) } - let(:project) { create(:project) } - let(:board) { create(:board, project: project) } - - let(:m1) { create(:milestone, project: project) } - let(:m2) { create(:milestone, project: project) } - - let(:bug) { create(:label, project: project, name: 'Bug') } - let(:development) { create(:label, project: project, name: 'Development') } - let(:testing) { create(:label, project: project, name: 'Testing') } - let(:p1) { create(:label, title: 'P1', project: project, priority: 1) } - let(:p2) { create(:label, title: 'P2', project: project, priority: 2) } - let(:p3) { create(:label, title: 'P3', project: project, priority: 3) } - - let!(:backlog) { create(:backlog_list, board: board) } - let!(:list1) { create(:list, board: board, label: development, position: 0) } - let!(:list2) { create(:list, board: board, label: testing, position: 1) } - let!(:closed) { create(:closed_list, board: board) } + let_it_be(:user) { create(:user) } - let!(:opened_issue1) { create(:labeled_issue, project: project, milestone: m1, title: 'Issue 1', labels: [bug]) } - let!(:opened_issue2) { create(:labeled_issue, project: project, milestone: m2, title: 'Issue 2', labels: [p2]) } - let!(:reopened_issue1) { create(:issue, :opened, project: project, title: 'Reopened Issue 1' ) } - - let!(:list1_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [p2, development]) } - let!(:list1_issue2) { create(:labeled_issue, project: project, milestone: m2, labels: [development]) } - let!(:list1_issue3) { create(:labeled_issue, project: project, milestone: m1, labels: [development, p1]) } - let!(:list2_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [testing]) } - - let!(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug], closed_at: 1.day.ago) } - let!(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3], closed_at: 2.days.ago) } - let!(:closed_issue3) { create(:issue, :closed, project: project, closed_at: 1.week.ago) } - let!(:closed_issue4) { create(:labeled_issue, :closed, project: project, labels: [p1], closed_at: 1.year.ago) } - let!(:closed_issue5) { create(:labeled_issue, :closed, project: project, labels: [development], closed_at: 2.years.ago) } + context 'when parent is a project' do + let_it_be(:project) { create(:project, :empty_repo) } + let_it_be(:board) { create(:board, project: project) } + + let_it_be(:m1) { create(:milestone, project: project) } + let_it_be(:m2) { create(:milestone, project: project) } + + let_it_be(:bug) { create(:label, project: project, name: 'Bug') } + let_it_be(:development) { create(:label, project: project, name: 'Development') } + let_it_be(:testing) { create(:label, project: project, name: 'Testing') } + let_it_be(:p1) { create(:label, title: 'P1', project: project, priority: 1) } + let_it_be(:p2) { create(:label, title: 'P2', project: project, priority: 2) } + let_it_be(:p3) { create(:label, title: 'P3', project: project, priority: 3) } + + let_it_be(:backlog) { create(:backlog_list, board: board) } + let_it_be(:list1) { create(:list, board: board, label: development, position: 0) } + let_it_be(:list2) { create(:list, board: board, label: testing, position: 1) } + let_it_be(:closed) { create(:closed_list, board: board) } + + let_it_be(:opened_issue1) { create(:labeled_issue, project: project, milestone: m1, title: 'Issue 1', labels: [bug]) } + let_it_be(:opened_issue2) { create(:labeled_issue, project: project, milestone: m2, title: 'Issue 2', labels: [p2]) } + let_it_be(:reopened_issue1) { create(:issue, :opened, project: project, title: 'Reopened Issue 1' ) } + + let_it_be(:list1_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [p2, development]) } + let_it_be(:list1_issue2) { create(:labeled_issue, project: project, milestone: m2, labels: [development]) } + let_it_be(:list1_issue3) { create(:labeled_issue, project: project, milestone: m1, labels: [development, p1]) } + let_it_be(:list2_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [testing]) } + + let_it_be(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug], closed_at: 1.day.ago) } + let_it_be(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3], closed_at: 2.days.ago) } + let_it_be(:closed_issue3) { create(:issue, :closed, project: project, closed_at: 1.week.ago) } + let_it_be(:closed_issue4) { create(:labeled_issue, :closed, project: project, labels: [p1], closed_at: 1.year.ago) } + let_it_be(:closed_issue5) { create(:labeled_issue, :closed, project: project, labels: [development], closed_at: 2.years.ago) } let(:parent) { project } @@ -48,14 +49,16 @@ RSpec.describe Boards::Issues::ListService do it_behaves_like 'issues list service' context 'when project is archived' do - let(:project) { create(:project, :archived) } + before do + project.update!(archived: true) + end it_behaves_like 'issues list service' end end + # rubocop: disable RSpec/MultipleMemoizedHelpers context 'when parent is a group' do - let(:user) { create(:user) } let(:project) { create(:project, :empty_repo, namespace: group) } let(:project1) { create(:project, :empty_repo, namespace: group) } let(:project_archived) { create(:project, :empty_repo, :archived, namespace: group) } @@ -104,7 +107,7 @@ RSpec.describe Boards::Issues::ListService do group.add_developer(user) end - context 'and group has no parent' do + context 'when the group has no parent' do let(:parent) { group } let(:group) { create(:group) } let(:board) { create(:board, group: group) } @@ -112,7 +115,7 @@ RSpec.describe Boards::Issues::ListService do it_behaves_like 'issues list service' end - context 'and group is an ancestor' do + context 'when the group is an ancestor' do let(:parent) { create(:group) } let(:group) { create(:group, parent: parent) } let!(:backlog) { create(:backlog_list, board: board) } @@ -125,5 +128,6 @@ RSpec.describe Boards::Issues::ListService do it_behaves_like 'issues list service' end end + # rubocop: enable RSpec/MultipleMemoizedHelpers end end diff --git a/spec/services/boards/lists/list_service_spec.rb b/spec/services/boards/lists/list_service_spec.rb index dfe65f3d241..21619abf6aa 100644 --- a/spec/services/boards/lists/list_service_spec.rb +++ b/spec/services/boards/lists/list_service_spec.rb @@ -8,6 +8,26 @@ RSpec.describe Boards::Lists::ListService do describe '#execute' do let(:service) { described_class.new(parent, user) } + shared_examples 'hidden lists' do + let!(:list) { create(:list, board: board, label: label) } + + context 'when hide_backlog_list is true' do + it 'hides backlog list' do + board.update!(hide_backlog_list: true) + + expect(service.execute(board)).to match_array([board.closed_list, list]) + end + end + + context 'when hide_closed_list is true' do + it 'hides closed list' do + board.update!(hide_closed_list: true) + + expect(service.execute(board)).to match_array([board.backlog_list, list]) + end + end + end + context 'when board parent is a project' do let(:project) { create(:project) } let(:board) { create(:board, project: project) } @@ -16,6 +36,7 @@ RSpec.describe Boards::Lists::ListService do let(:parent) { project } it_behaves_like 'lists list service' + it_behaves_like 'hidden lists' end context 'when board parent is a group' do @@ -26,6 +47,7 @@ RSpec.describe Boards::Lists::ListService do let(:parent) { group } it_behaves_like 'lists list service' + it_behaves_like 'hidden lists' end end end diff --git a/spec/services/bulk_import_service_spec.rb b/spec/services/bulk_import_service_spec.rb index e4a50b9d523..1b60a5cb0f8 100644 --- a/spec/services/bulk_import_service_spec.rb +++ b/spec/services/bulk_import_service_spec.rb @@ -48,5 +48,22 @@ RSpec.describe BulkImportService do subject.execute end + + it 'returns success ServiceResponse' do + result = subject.execute + + expect(result).to be_a(ServiceResponse) + expect(result).to be_success + end + + it 'returns ServiceResponse with error if validation fails' do + params[0][:source_full_path] = nil + + result = subject.execute + + expect(result).to be_a(ServiceResponse) + expect(result).to be_error + expect(result.message).to eq("Validation failed: Source full path can't be blank") + end end end diff --git a/spec/services/ci/build_report_result_service_spec.rb b/spec/services/ci/build_report_result_service_spec.rb index 7c2702af086..c5238b7f5e0 100644 --- a/spec/services/ci/build_report_result_service_spec.rb +++ b/spec/services/ci/build_report_result_service_spec.rb @@ -10,13 +10,17 @@ RSpec.describe Ci::BuildReportResultService do let(:build) { create(:ci_build, :success, :test_reports) } it 'creates a build report result entry', :aggregate_failures do + expect { build_report_result }.to change { Ci::BuildReportResult.count }.by(1) expect(build_report_result.tests_name).to eq("test") expect(build_report_result.tests_success).to eq(2) expect(build_report_result.tests_failed).to eq(2) expect(build_report_result.tests_errored).to eq(0) expect(build_report_result.tests_skipped).to eq(0) expect(build_report_result.tests_duration).to eq(0.010284) - expect(Ci::BuildReportResult.count).to eq(1) + end + + it 'tracks unique test cases parsed' do + build_report_result unique_test_cases_parsed = Gitlab::UsageDataCounters::HLLRedisCounter.unique_events( event_names: described_class::EVENT_NAME, @@ -26,6 +30,32 @@ RSpec.describe Ci::BuildReportResultService do expect(unique_test_cases_parsed).to eq(4) end + context 'and build has test report parsing errors' do + let(:build) { create(:ci_build, :success, :broken_test_reports) } + + it 'creates a build report result entry with suite error', :aggregate_failures do + expect { build_report_result }.to change { Ci::BuildReportResult.count }.by(1) + expect(build_report_result.tests_name).to eq("test") + expect(build_report_result.tests_success).to eq(0) + expect(build_report_result.tests_failed).to eq(0) + expect(build_report_result.tests_errored).to eq(0) + expect(build_report_result.tests_skipped).to eq(0) + expect(build_report_result.tests_duration).to eq(0) + expect(build_report_result.suite_error).to be_present + end + + it 'does not track unique test cases parsed' do + build_report_result + + unique_test_cases_parsed = Gitlab::UsageDataCounters::HLLRedisCounter.unique_events( + event_names: described_class::EVENT_NAME, + start_date: 2.weeks.ago, + end_date: 2.weeks.from_now + ) + expect(unique_test_cases_parsed).to eq(0) + end + end + context 'when data has already been persisted' do it 'raises an error and do not persist the same data twice' do expect { 2.times { described_class.new.execute(build) } }.to raise_error(ActiveRecord::RecordNotUnique) diff --git a/spec/services/ci/create_pipeline_service/environment_spec.rb b/spec/services/ci/create_pipeline_service/environment_spec.rb new file mode 100644 index 00000000000..0ed63012325 --- /dev/null +++ b/spec/services/ci/create_pipeline_service/environment_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::CreatePipelineService do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:developer) { create(:user) } + let(:service) { described_class.new(project, user, ref: 'master') } + let(:user) { developer } + + before_all do + project.add_developer(developer) + end + + describe '#execute' do + subject { service.execute(:push) } + + context 'with deployment tier' do + before do + config = YAML.dump( + deploy: { + script: 'ls', + environment: { name: "review/$CI_COMMIT_REF_NAME", deployment_tier: tier } + }) + + stub_ci_pipeline_yaml_file(config) + end + + let(:tier) { 'development' } + + it 'creates the environment with the expected tier' do + is_expected.to be_created_successfully + + expect(Environment.find_by_name("review/master")).to be_development + end + + context 'when tier is testing' do + let(:tier) { 'testing' } + + it 'creates the environment with the expected tier' do + is_expected.to be_created_successfully + + expect(Environment.find_by_name("review/master")).to be_testing + end + end + end + end +end diff --git a/spec/services/ci/create_pipeline_service/needs_spec.rb b/spec/services/ci/create_pipeline_service/needs_spec.rb index 512091035a2..a6b0a9662c9 100644 --- a/spec/services/ci/create_pipeline_service/needs_spec.rb +++ b/spec/services/ci/create_pipeline_service/needs_spec.rb @@ -238,5 +238,51 @@ RSpec.describe Ci::CreatePipelineService do .to eq('jobs:invalid_dag_job:needs config can not be an empty hash') end end + + context 'when the needed job has rules' do + let(:config) do + <<~YAML + build: + stage: build + script: exit 0 + rules: + - if: $CI_COMMIT_REF_NAME == "invalid" + + test: + stage: test + script: exit 0 + needs: [build] + YAML + end + + it 'returns error' do + expect(pipeline.yaml_errors) + .to eq("'test' job needs 'build' job, but it was not added to the pipeline") + end + + context 'when need is optional' do + let(:config) do + <<~YAML + build: + stage: build + script: exit 0 + rules: + - if: $CI_COMMIT_REF_NAME == "invalid" + + test: + stage: test + script: exit 0 + needs: + - job: build + optional: true + YAML + end + + it 'creates the pipeline without an error' do + expect(pipeline).to be_persisted + expect(pipeline.builds.pluck(:name)).to contain_exactly('test') + end + end + end end end diff --git a/spec/services/ci/create_pipeline_service/parallel_spec.rb b/spec/services/ci/create_pipeline_service/parallel_spec.rb new file mode 100644 index 00000000000..5e34a67d376 --- /dev/null +++ b/spec/services/ci/create_pipeline_service/parallel_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Ci::CreatePipelineService do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { project.owner } + + let(:service) { described_class.new(project, user, { ref: 'master' }) } + let(:pipeline) { service.execute(:push) } + + before do + stub_ci_pipeline_yaml_file(config) + end + + context 'job:parallel' do + context 'numeric' do + let(:config) do + <<-EOY + job: + script: "echo job" + parallel: 3 + EOY + end + + it 'creates the pipeline' do + expect(pipeline).to be_created_successfully + end + + it 'creates 3 jobs' do + expect(pipeline.processables.pluck(:name)).to contain_exactly( + 'job 1/3', 'job 2/3', 'job 3/3' + ) + end + end + + context 'matrix' do + let(:config) do + <<-EOY + job: + script: "echo job" + parallel: + matrix: + - PROVIDER: ovh + STACK: [monitoring, app] + - PROVIDER: [gcp, vultr] + STACK: [data] + EOY + end + + it 'creates the pipeline' do + expect(pipeline).to be_created_successfully + end + + it 'creates 4 builds with the corresponding matrix variables' do + expect(pipeline.processables.pluck(:name)).to contain_exactly( + 'job: [gcp, data]', 'job: [ovh, app]', 'job: [ovh, monitoring]', 'job: [vultr, data]' + ) + + job1 = find_job('job: [gcp, data]') + job2 = find_job('job: [ovh, app]') + job3 = find_job('job: [ovh, monitoring]') + job4 = find_job('job: [vultr, data]') + + expect(job1.scoped_variables.to_hash).to include('PROVIDER' => 'gcp', 'STACK' => 'data') + expect(job2.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'app') + expect(job3.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'monitoring') + expect(job4.scoped_variables.to_hash).to include('PROVIDER' => 'vultr', 'STACK' => 'data') + end + + context 'when a bridge is using parallel:matrix' do + let(:config) do + <<-EOY + job: + stage: test + script: "echo job" + + deploy: + stage: deploy + trigger: + include: child.yml + parallel: + matrix: + - PROVIDER: ovh + STACK: [monitoring, app] + - PROVIDER: [gcp, vultr] + STACK: [data] + EOY + end + + it 'creates the pipeline' do + expect(pipeline).to be_created_successfully + end + + it 'creates 1 build and 4 bridges with the corresponding matrix variables' do + expect(pipeline.processables.pluck(:name)).to contain_exactly( + 'job', 'deploy: [gcp, data]', 'deploy: [ovh, app]', 'deploy: [ovh, monitoring]', 'deploy: [vultr, data]' + ) + + bridge1 = find_job('deploy: [gcp, data]') + bridge2 = find_job('deploy: [ovh, app]') + bridge3 = find_job('deploy: [ovh, monitoring]') + bridge4 = find_job('deploy: [vultr, data]') + + expect(bridge1.scoped_variables.to_hash).to include('PROVIDER' => 'gcp', 'STACK' => 'data') + expect(bridge2.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'app') + expect(bridge3.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'monitoring') + expect(bridge4.scoped_variables.to_hash).to include('PROVIDER' => 'vultr', 'STACK' => 'data') + end + end + end + end + + private + + def find_job(name) + pipeline.processables.find { |job| job.name == name } + end +end diff --git a/spec/services/ci/create_pipeline_service/rules_spec.rb b/spec/services/ci/create_pipeline_service/rules_spec.rb index 04ecac6a85a..e97e74c1515 100644 --- a/spec/services/ci/create_pipeline_service/rules_spec.rb +++ b/spec/services/ci/create_pipeline_service/rules_spec.rb @@ -174,33 +174,19 @@ RSpec.describe Ci::CreatePipelineService do let(:ref) { 'refs/heads/master' } it 'overrides VAR1' do - variables = job.scoped_variables_hash + variables = job.scoped_variables.to_hash expect(variables['VAR1']).to eq('overridden var 1') expect(variables['VAR2']).to eq('my var 2') expect(variables['VAR3']).to be_nil end - - context 'when FF ci_rules_variables is disabled' do - before do - stub_feature_flags(ci_rules_variables: false) - end - - it 'does not affect variables' do - variables = job.scoped_variables_hash - - expect(variables['VAR1']).to eq('my var 1') - expect(variables['VAR2']).to eq('my var 2') - expect(variables['VAR3']).to be_nil - end - end end context 'when matching to the second rule' do let(:ref) { 'refs/heads/feature' } it 'overrides VAR2 and adds VAR3' do - variables = job.scoped_variables_hash + variables = job.scoped_variables.to_hash expect(variables['VAR1']).to eq('my var 1') expect(variables['VAR2']).to eq('overridden var 2') @@ -212,7 +198,7 @@ RSpec.describe Ci::CreatePipelineService do let(:ref) { 'refs/heads/wip' } it 'does not affect vars' do - variables = job.scoped_variables_hash + variables = job.scoped_variables.to_hash expect(variables['VAR1']).to eq('my var 1') expect(variables['VAR2']).to eq('my var 2') diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index 1005985b3e4..9fafc57a770 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -101,7 +101,7 @@ RSpec.describe Ci::CreatePipelineService do describe 'recording a conversion event' do it 'schedules a record conversion event worker' do - expect(Experiments::RecordConversionEventWorker).to receive(:perform_async).with(:ci_syntax_templates, user.id) + expect(Experiments::RecordConversionEventWorker).to receive(:perform_async).with(:ci_syntax_templates_b, user.id) pipeline end diff --git a/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb b/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb index 1edcef2977b..d315dd35632 100644 --- a/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb +++ b/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb @@ -77,14 +77,6 @@ RSpec.describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared it 'does not remove the files' do expect { subject }.not_to change { artifact.file.exists? } end - - it 'reports metrics for destroyed artifacts' do - counter = service.send(:destroyed_artifacts_counter) - - expect(counter).to receive(:increment).with({}, 1).and_call_original - - subject - end end end @@ -244,5 +236,17 @@ RSpec.describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared expect { subject }.to change { Ci::JobArtifact.count }.by(-1) end end + + context 'when all artifacts are locked' do + before do + pipeline = create(:ci_pipeline, locked: :artifacts_locked) + job = create(:ci_build, pipeline: pipeline) + artifact.update!(job: job) + end + + it 'destroys no artifacts' do + expect { subject }.to change { Ci::JobArtifact.count }.by(0) + end + end end end diff --git a/spec/services/ci/expire_pipeline_cache_service_spec.rb b/spec/services/ci/expire_pipeline_cache_service_spec.rb index 8df5d0bc159..3dbf2dbb8f1 100644 --- a/spec/services/ci/expire_pipeline_cache_service_spec.rb +++ b/spec/services/ci/expire_pipeline_cache_service_spec.rb @@ -13,10 +13,14 @@ RSpec.describe Ci::ExpirePipelineCacheService do pipelines_path = "/#{project.full_path}/-/pipelines.json" new_mr_pipelines_path = "/#{project.full_path}/-/merge_requests/new.json" pipeline_path = "/#{project.full_path}/-/pipelines/#{pipeline.id}.json" + graphql_pipeline_path = "/api/graphql:pipelines/id/#{pipeline.id}" - expect_any_instance_of(Gitlab::EtagCaching::Store).to receive(:touch).with(pipelines_path) - expect_any_instance_of(Gitlab::EtagCaching::Store).to receive(:touch).with(new_mr_pipelines_path) - expect_any_instance_of(Gitlab::EtagCaching::Store).to receive(:touch).with(pipeline_path) + expect_next_instance_of(Gitlab::EtagCaching::Store) do |store| + expect(store).to receive(:touch).with(pipelines_path) + expect(store).to receive(:touch).with(new_mr_pipelines_path) + expect(store).to receive(:touch).with(pipeline_path) + expect(store).to receive(:touch).with(graphql_pipeline_path) + end subject.execute(pipeline) end @@ -59,5 +63,36 @@ RSpec.describe Ci::ExpirePipelineCacheService do expect(Project.find(project_with_repo.id).pipeline_status.has_status?).to be_falsey end end + + context 'when the pipeline is triggered by another pipeline' do + let(:source) { create(:ci_sources_pipeline, pipeline: pipeline) } + + it 'updates the cache of dependent pipeline' do + dependent_pipeline_path = "/#{source.source_project.full_path}/-/pipelines/#{source.source_pipeline.id}.json" + + expect_next_instance_of(Gitlab::EtagCaching::Store) do |store| + allow(store).to receive(:touch) + expect(store).to receive(:touch).with(dependent_pipeline_path) + end + + subject.execute(pipeline) + end + end + + context 'when the pipeline triggered another pipeline' do + let(:build) { create(:ci_build, pipeline: pipeline) } + let(:source) { create(:ci_sources_pipeline, source_job: build) } + + it 'updates the cache of dependent pipeline' do + dependent_pipeline_path = "/#{source.project.full_path}/-/pipelines/#{source.pipeline.id}.json" + + expect_next_instance_of(Gitlab::EtagCaching::Store) do |store| + allow(store).to receive(:touch) + expect(store).to receive(:touch).with(dependent_pipeline_path) + end + + subject.execute(pipeline) + end + end end end diff --git a/spec/services/ci/job_artifacts_destroy_batch_service_spec.rb b/spec/services/ci/job_artifacts_destroy_batch_service_spec.rb new file mode 100644 index 00000000000..74fbbf28ef1 --- /dev/null +++ b/spec/services/ci/job_artifacts_destroy_batch_service_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::JobArtifactsDestroyBatchService do + include ExclusiveLeaseHelpers + + let(:artifacts) { Ci::JobArtifact.all } + let(:service) { described_class.new(artifacts, pick_up_at: Time.current) } + + describe '.execute' do + subject(:execute) { service.execute } + + let_it_be(:artifact, refind: true) do + create(:ci_job_artifact) + end + + context 'when the artifact has a file attached to it' do + before do + artifact.file = fixture_file_upload(Rails.root.join('spec/fixtures/ci_build_artifacts.zip'), 'application/zip') + artifact.save! + end + + it 'creates a deleted object' do + expect { subject }.to change { Ci::DeletedObject.count }.by(1) + end + + it 'resets project statistics' do + expect(ProjectStatistics).to receive(:increment_statistic).once + .with(artifact.project, :build_artifacts_size, -artifact.file.size) + .and_call_original + + execute + end + + it 'does not remove the files' do + expect { execute }.not_to change { artifact.file.exists? } + end + + it 'reports metrics for destroyed artifacts' do + expect_next_instance_of(Gitlab::Ci::Artifacts::Metrics) do |metrics| + expect(metrics).to receive(:increment_destroyed_artifacts).with(1).and_call_original + end + + execute + end + end + + context 'when failed to destroy artifact' do + context 'when the import fails' do + before do + expect(Ci::DeletedObject) + .to receive(:bulk_import) + .once + .and_raise(ActiveRecord::RecordNotDestroyed) + end + + it 'raises an exception and stop destroying' do + expect { execute }.to raise_error(ActiveRecord::RecordNotDestroyed) + .and not_change { Ci::JobArtifact.count }.from(1) + end + end + end + + context 'when there are no artifacts' do + let(:artifacts) { Ci::JobArtifact.none } + + before do + artifact.destroy! + end + + it 'does not raise error' do + expect { execute }.not_to raise_error + end + + it 'reports the number of destroyed artifacts' do + is_expected.to eq(destroyed_artifacts_count: 0, status: :success) + end + end + end +end diff --git a/spec/services/ci/pipeline_processing/shared_processing_service.rb b/spec/services/ci/pipeline_processing/shared_processing_service.rb index bbd7422b435..13c924a3089 100644 --- a/spec/services/ci/pipeline_processing/shared_processing_service.rb +++ b/spec/services/ci/pipeline_processing/shared_processing_service.rb @@ -1,21 +1,13 @@ # frozen_string_literal: true RSpec.shared_examples 'Pipeline Processing Service' do - let(:user) { create(:user) } - let(:project) { create(:project) } + let(:project) { create(:project, :repository) } + let(:user) { project.owner } let(:pipeline) do create(:ci_empty_pipeline, ref: 'master', project: project) end - before do - stub_ci_pipeline_to_return_yaml_file - - stub_not_protect_default_branch - - project.add_developer(user) - end - context 'when simple pipeline is defined' do before do create_build('linux', stage_idx: 0) @@ -843,19 +835,97 @@ RSpec.shared_examples 'Pipeline Processing Service' do create(:ci_build_need, build: deploy, name: 'linux:build') end - it 'makes deploy DAG to be waiting for optional manual to finish' do + it 'makes deploy DAG to be skipped' do expect(process_pipeline).to be_truthy - expect(stages).to eq(%w(skipped created)) + expect(stages).to eq(%w(skipped skipped)) expect(all_builds.manual).to contain_exactly(linux_build) - expect(all_builds.created).to contain_exactly(deploy) + expect(all_builds.skipped).to contain_exactly(deploy) + end + + context 'when FF ci_fix_pipeline_status_for_dag_needs_manual is disabled' do + before do + stub_feature_flags(ci_fix_pipeline_status_for_dag_needs_manual: false) + end + + it 'makes deploy DAG to be waiting for optional manual to finish' do + expect(process_pipeline).to be_truthy + + expect(stages).to eq(%w(skipped created)) + expect(all_builds.manual).to contain_exactly(linux_build) + expect(all_builds.created).to contain_exactly(deploy) + end + end + end + + context 'when a bridge job has parallel:matrix config', :sidekiq_inline do + let(:parent_config) do + <<-EOY + test: + stage: test + script: echo test + + deploy: + stage: deploy + trigger: + include: .child.yml + parallel: + matrix: + - PROVIDER: ovh + STACK: [monitoring, app] + EOY + end + + let(:child_config) do + <<-EOY + test: + stage: test + script: echo test + EOY + end + + let(:pipeline) do + Ci::CreatePipelineService.new(project, user, { ref: 'master' }).execute(:push) + end + + before do + allow_next_instance_of(Repository) do |repository| + allow(repository) + .to receive(:blob_data_at) + .with(an_instance_of(String), '.gitlab-ci.yml') + .and_return(parent_config) + + allow(repository) + .to receive(:blob_data_at) + .with(an_instance_of(String), '.child.yml') + .and_return(child_config) + end + end + + it 'creates pipeline with bridges, then passes the matrix variables to downstream jobs' do + expect(all_builds_names).to contain_exactly('test', 'deploy: [ovh, monitoring]', 'deploy: [ovh, app]') + expect(all_builds_statuses).to contain_exactly('pending', 'created', 'created') + + succeed_pending + + # bridge jobs directly transition to success + expect(all_builds_statuses).to contain_exactly('success', 'success', 'success') + + bridge1 = all_builds.find_by(name: 'deploy: [ovh, monitoring]') + bridge2 = all_builds.find_by(name: 'deploy: [ovh, app]') + + downstream_job1 = bridge1.downstream_pipeline.processables.first + downstream_job2 = bridge2.downstream_pipeline.processables.first + + expect(downstream_job1.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'monitoring') + expect(downstream_job2.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'app') end end private def all_builds - pipeline.builds.order(:stage_idx, :id) + pipeline.processables.order(:stage_idx, :id) end def builds diff --git a/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb b/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb index 2936d6fae4d..a9f9db8c689 100644 --- a/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb +++ b/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb @@ -1,21 +1,19 @@ # frozen_string_literal: true RSpec.shared_context 'Pipeline Processing Service Tests With Yaml' do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { project.owner } + where(:test_file_path) do Dir.glob(Rails.root.join('spec/services/ci/pipeline_processing/test_cases/*.yml')) end with_them do let(:test_file) { YAML.load_file(test_file_path) } - - let(:user) { create(:user) } - let(:project) { create(:project, :repository) } let(:pipeline) { Ci::CreatePipelineService.new(project, user, ref: 'master').execute(:pipeline) } before do stub_ci_pipeline_yaml_file(YAML.dump(test_file['config'])) - stub_not_protect_default_branch - project.add_developer(user) end it 'follows transitions' do diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build2_build1_rules_out_test_needs_build1_with_optional.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build2_build1_rules_out_test_needs_build1_with_optional.yml new file mode 100644 index 00000000000..170e1b589bb --- /dev/null +++ b/spec/services/ci/pipeline_processing/test_cases/dag_build2_build1_rules_out_test_needs_build1_with_optional.yml @@ -0,0 +1,50 @@ +config: + build1: + stage: build + script: exit 0 + rules: + - if: $CI_COMMIT_REF_NAME == "invalid" + + build2: + stage: build + script: exit 0 + + test: + stage: test + script: exit 0 + needs: + - job: build1 + optional: true + +init: + expect: + pipeline: pending + stages: + build: pending + test: pending + jobs: + build2: pending + test: pending + +transitions: + - event: success + jobs: [test] + expect: + pipeline: running + stages: + build: pending + test: success + jobs: + build2: pending + test: success + + - event: success + jobs: [build2] + expect: + pipeline: success + stages: + build: success + test: success + jobs: + build2: success + test: success diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_rules_out_test_needs_build_with_optional.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_rules_out_test_needs_build_with_optional.yml new file mode 100644 index 00000000000..85e7aa04a24 --- /dev/null +++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_rules_out_test_needs_build_with_optional.yml @@ -0,0 +1,31 @@ +config: + build: + stage: build + script: exit 0 + rules: + - if: $CI_COMMIT_REF_NAME == "invalid" + + test: + stage: test + script: exit 0 + needs: + - job: build + optional: true + +init: + expect: + pipeline: pending + stages: + test: pending + jobs: + test: pending + +transitions: + - event: success + jobs: [test] + expect: + pipeline: success + stages: + test: success + jobs: + test: success diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_both.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_both.yml index 60f803bc3d0..96377b00c85 100644 --- a/spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_both.yml +++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_both.yml @@ -30,12 +30,12 @@ transitions: - event: success jobs: [build] expect: - pipeline: running + pipeline: success stages: build: success test: skipped - deploy: created + deploy: skipped jobs: build: success test: manual - deploy: created + deploy: skipped diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_test.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_test.yml index 4e4b2f22224..69640630ef4 100644 --- a/spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_test.yml +++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_test.yml @@ -30,12 +30,12 @@ transitions: - event: success jobs: [build] expect: - pipeline: running + pipeline: success stages: build: success test: skipped - deploy: created + deploy: skipped jobs: build: success test: manual - deploy: created + deploy: skipped diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_test_manual_review_deploy.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_test_manual_review_deploy.yml index fef28dcfbbe..8de484d6793 100644 --- a/spec/services/ci/pipeline_processing/test_cases/dag_build_test_manual_review_deploy.yml +++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_test_manual_review_deploy.yml @@ -54,29 +54,29 @@ transitions: stages: build: success test: pending - review: created - deploy: created + review: skipped + deploy: skipped jobs: build: success test: pending release_test: manual - review: created - staging: created - production: created + review: skipped + staging: skipped + production: skipped - event: success jobs: [test] expect: - pipeline: running + pipeline: success stages: build: success test: success - review: created - deploy: created + review: skipped + deploy: skipped jobs: build: success test: success release_test: manual - review: created - staging: created - production: created + review: skipped + staging: skipped + production: skipped diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true.yml b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true.yml index d8ca563b141..b8fcdd1566a 100644 --- a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true.yml +++ b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true.yml @@ -12,13 +12,13 @@ config: init: expect: - pipeline: created + pipeline: skipped stages: test: skipped - deploy: created + deploy: skipped jobs: test: manual - deploy: created + deploy: skipped transitions: - event: enqueue @@ -27,10 +27,10 @@ transitions: pipeline: pending stages: test: pending - deploy: created + deploy: skipped jobs: test: pending - deploy: created + deploy: skipped - event: run jobs: [test] @@ -38,21 +38,18 @@ transitions: pipeline: running stages: test: running - deploy: created + deploy: skipped jobs: test: running - deploy: created + deploy: skipped - event: drop jobs: [test] expect: - pipeline: running + pipeline: success stages: test: success - deploy: pending + deploy: skipped jobs: test: failed - deploy: pending - -# TOOD: should we run deploy? -# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080 + deploy: skipped diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_always.yml b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_always.yml index ba0a20f49a7..a4a98bf4629 100644 --- a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_always.yml +++ b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_always.yml @@ -13,15 +13,12 @@ config: init: expect: - pipeline: created + pipeline: pending stages: test: skipped - deploy: created + deploy: pending jobs: test: manual - deploy: created + deploy: pending transitions: [] - -# TODO: should we run `deploy`? -# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080 diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_on_failure.yml b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_on_failure.yml index d375c6a49e0..81aad4940b6 100644 --- a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_on_failure.yml +++ b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_on_failure.yml @@ -13,13 +13,13 @@ config: init: expect: - pipeline: created + pipeline: skipped stages: test: skipped - deploy: created + deploy: skipped jobs: test: manual - deploy: created + deploy: skipped transitions: - event: enqueue @@ -28,10 +28,10 @@ transitions: pipeline: pending stages: test: pending - deploy: created + deploy: skipped jobs: test: pending - deploy: created + deploy: skipped - event: drop jobs: [test] @@ -43,6 +43,3 @@ transitions: jobs: test: failed deploy: skipped - -# TODO: should we run `deploy`? -# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080 diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_other_test_succeeds_deploy_needs_both.yml b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_other_test_succeeds_deploy_needs_both.yml index 34073b92ccc..a5bb103d1a5 100644 --- a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_other_test_succeeds_deploy_needs_both.yml +++ b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_other_test_succeeds_deploy_needs_both.yml @@ -19,24 +19,21 @@ init: pipeline: pending stages: test: pending - deploy: created + deploy: skipped jobs: test1: pending test2: manual - deploy: created + deploy: skipped transitions: - event: success jobs: [test1] expect: - pipeline: running + pipeline: success stages: test: success - deploy: created + deploy: skipped jobs: test1: success test2: manual - deploy: created - -# TODO: should deploy run? -# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080 + deploy: skipped diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index d316c9a262b..e02536fd07f 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -43,42 +43,59 @@ RSpec.describe Ci::ProcessPipelineService do let!(:build) { create_build('build') } let!(:test) { create_build('test') } - it 'returns unique statuses' do - subject.execute + context 'when FF ci_remove_update_retried_from_process_pipeline is enabled' do + it 'does not update older builds as retried' do + subject.execute - expect(all_builds.latest).to contain_exactly(build, test) - expect(all_builds.retried).to contain_exactly(build_retried) + expect(all_builds.latest).to contain_exactly(build, build_retried, test) + expect(all_builds.retried).to be_empty + end end - context 'counter ci_legacy_update_jobs_as_retried_total' do - let(:counter) { double(increment: true) } - + context 'when FF ci_remove_update_retried_from_process_pipeline is disabled' do before do - allow(Gitlab::Metrics).to receive(:counter).and_call_original - allow(Gitlab::Metrics).to receive(:counter) - .with(:ci_legacy_update_jobs_as_retried_total, anything) - .and_return(counter) + stub_feature_flags(ci_remove_update_retried_from_process_pipeline: false) end - it 'increments the counter' do - expect(counter).to receive(:increment) - + it 'returns unique statuses' do subject.execute + + expect(all_builds.latest).to contain_exactly(build, test) + expect(all_builds.retried).to contain_exactly(build_retried) end - context 'when the previous build has already retried column true' do + context 'counter ci_legacy_update_jobs_as_retried_total' do + let(:counter) { double(increment: true) } + before do - build_retried.update_columns(retried: true) + allow(Gitlab::Metrics).to receive(:counter).and_call_original + allow(Gitlab::Metrics).to receive(:counter) + .with(:ci_legacy_update_jobs_as_retried_total, anything) + .and_return(counter) end - it 'does not increment the counter' do - expect(counter).not_to receive(:increment) + it 'increments the counter' do + expect(counter).to receive(:increment) subject.execute end + + context 'when the previous build has already retried column true' do + before do + build_retried.update_columns(retried: true) + end + + it 'does not increment the counter' do + expect(counter).not_to receive(:increment) + + subject.execute + end + end end end + private + def create_build(name, **opts) create(:ci_build, :created, pipeline: pipeline, name: name, **opts) end diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index 88770c8095b..9187dd4f300 100644 --- a/spec/services/ci/register_job_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -13,573 +13,656 @@ module Ci let!(:pending_job) { create(:ci_build, pipeline: pipeline) } describe '#execute' do - context 'runner follow tag list' do - it "picks build with the same tag" do - pending_job.update!(tag_list: ["linux"]) - specific_runner.update!(tag_list: ["linux"]) - expect(execute(specific_runner)).to eq(pending_job) - end - - it "does not pick build with different tag" do - pending_job.update!(tag_list: ["linux"]) - specific_runner.update!(tag_list: ["win32"]) - expect(execute(specific_runner)).to be_falsey - end + shared_examples 'handles runner assignment' do + context 'runner follow tag list' do + it "picks build with the same tag" do + pending_job.update!(tag_list: ["linux"]) + specific_runner.update!(tag_list: ["linux"]) + expect(execute(specific_runner)).to eq(pending_job) + end - it "picks build without tag" do - expect(execute(specific_runner)).to eq(pending_job) - end + it "does not pick build with different tag" do + pending_job.update!(tag_list: ["linux"]) + specific_runner.update!(tag_list: ["win32"]) + expect(execute(specific_runner)).to be_falsey + end - it "does not pick build with tag" do - pending_job.update!(tag_list: ["linux"]) - expect(execute(specific_runner)).to be_falsey - end + it "picks build without tag" do + expect(execute(specific_runner)).to eq(pending_job) + end - it "pick build without tag" do - specific_runner.update!(tag_list: ["win32"]) - expect(execute(specific_runner)).to eq(pending_job) - end - end + it "does not pick build with tag" do + pending_job.update!(tag_list: ["linux"]) + expect(execute(specific_runner)).to be_falsey + end - context 'deleted projects' do - before do - project.update!(pending_delete: true) + it "pick build without tag" do + specific_runner.update!(tag_list: ["win32"]) + expect(execute(specific_runner)).to eq(pending_job) + end end - context 'for shared runners' do + context 'deleted projects' do before do - project.update!(shared_runners_enabled: true) + project.update!(pending_delete: true) end - it 'does not pick a build' do - expect(execute(shared_runner)).to be_nil + context 'for shared runners' do + before do + project.update!(shared_runners_enabled: true) + end + + it 'does not pick a build' do + expect(execute(shared_runner)).to be_nil + end end - end - context 'for specific runner' do - it 'does not pick a build' do - expect(execute(specific_runner)).to be_nil + context 'for specific runner' do + it 'does not pick a build' do + expect(execute(specific_runner)).to be_nil + end end end - end - context 'allow shared runners' do - before do - project.update!(shared_runners_enabled: true) - end + context 'allow shared runners' do + before do + project.update!(shared_runners_enabled: true) + end + + context 'for multiple builds' do + let!(:project2) { create :project, shared_runners_enabled: true } + let!(:pipeline2) { create :ci_pipeline, project: project2 } + let!(:project3) { create :project, shared_runners_enabled: true } + let!(:pipeline3) { create :ci_pipeline, project: project3 } + let!(:build1_project1) { pending_job } + let!(:build2_project1) { FactoryBot.create :ci_build, pipeline: pipeline } + let!(:build3_project1) { FactoryBot.create :ci_build, pipeline: pipeline } + let!(:build1_project2) { FactoryBot.create :ci_build, pipeline: pipeline2 } + let!(:build2_project2) { FactoryBot.create :ci_build, pipeline: pipeline2 } + let!(:build1_project3) { FactoryBot.create :ci_build, pipeline: pipeline3 } + + it 'prefers projects without builds first' do + # it gets for one build from each of the projects + expect(execute(shared_runner)).to eq(build1_project1) + expect(execute(shared_runner)).to eq(build1_project2) + expect(execute(shared_runner)).to eq(build1_project3) + + # then it gets a second build from each of the projects + expect(execute(shared_runner)).to eq(build2_project1) + expect(execute(shared_runner)).to eq(build2_project2) + + # in the end the third build + expect(execute(shared_runner)).to eq(build3_project1) + end - context 'for multiple builds' do - let!(:project2) { create :project, shared_runners_enabled: true } - let!(:pipeline2) { create :ci_pipeline, project: project2 } - let!(:project3) { create :project, shared_runners_enabled: true } - let!(:pipeline3) { create :ci_pipeline, project: project3 } - let!(:build1_project1) { pending_job } - let!(:build2_project1) { FactoryBot.create :ci_build, pipeline: pipeline } - let!(:build3_project1) { FactoryBot.create :ci_build, pipeline: pipeline } - let!(:build1_project2) { FactoryBot.create :ci_build, pipeline: pipeline2 } - let!(:build2_project2) { FactoryBot.create :ci_build, pipeline: pipeline2 } - let!(:build1_project3) { FactoryBot.create :ci_build, pipeline: pipeline3 } - - it 'prefers projects without builds first' do - # it gets for one build from each of the projects - expect(execute(shared_runner)).to eq(build1_project1) - expect(execute(shared_runner)).to eq(build1_project2) - expect(execute(shared_runner)).to eq(build1_project3) - - # then it gets a second build from each of the projects - expect(execute(shared_runner)).to eq(build2_project1) - expect(execute(shared_runner)).to eq(build2_project2) - - # in the end the third build - expect(execute(shared_runner)).to eq(build3_project1) - end - - it 'equalises number of running builds' do - # after finishing the first build for project 1, get a second build from the same project - expect(execute(shared_runner)).to eq(build1_project1) - build1_project1.reload.success - expect(execute(shared_runner)).to eq(build2_project1) - - expect(execute(shared_runner)).to eq(build1_project2) - build1_project2.reload.success - expect(execute(shared_runner)).to eq(build2_project2) - expect(execute(shared_runner)).to eq(build1_project3) - expect(execute(shared_runner)).to eq(build3_project1) + it 'equalises number of running builds' do + # after finishing the first build for project 1, get a second build from the same project + expect(execute(shared_runner)).to eq(build1_project1) + build1_project1.reload.success + expect(execute(shared_runner)).to eq(build2_project1) + + expect(execute(shared_runner)).to eq(build1_project2) + build1_project2.reload.success + expect(execute(shared_runner)).to eq(build2_project2) + expect(execute(shared_runner)).to eq(build1_project3) + expect(execute(shared_runner)).to eq(build3_project1) + end end - end - context 'shared runner' do - let(:response) { described_class.new(shared_runner).execute } - let(:build) { response.build } + context 'shared runner' do + let(:response) { described_class.new(shared_runner).execute } + let(:build) { response.build } - it { expect(build).to be_kind_of(Build) } - it { expect(build).to be_valid } - it { expect(build).to be_running } - it { expect(build.runner).to eq(shared_runner) } - it { expect(Gitlab::Json.parse(response.build_json)['id']).to eq(build.id) } - end + it { expect(build).to be_kind_of(Build) } + it { expect(build).to be_valid } + it { expect(build).to be_running } + it { expect(build.runner).to eq(shared_runner) } + it { expect(Gitlab::Json.parse(response.build_json)['id']).to eq(build.id) } + end - context 'specific runner' do - let(:build) { execute(specific_runner) } + context 'specific runner' do + let(:build) { execute(specific_runner) } - it { expect(build).to be_kind_of(Build) } - it { expect(build).to be_valid } - it { expect(build).to be_running } - it { expect(build.runner).to eq(specific_runner) } + it { expect(build).to be_kind_of(Build) } + it { expect(build).to be_valid } + it { expect(build).to be_running } + it { expect(build.runner).to eq(specific_runner) } + end end - end - context 'disallow shared runners' do - before do - project.update!(shared_runners_enabled: false) - end + context 'disallow shared runners' do + before do + project.update!(shared_runners_enabled: false) + end - context 'shared runner' do - let(:build) { execute(shared_runner) } + context 'shared runner' do + let(:build) { execute(shared_runner) } - it { expect(build).to be_nil } - end + it { expect(build).to be_nil } + end - context 'specific runner' do - let(:build) { execute(specific_runner) } + context 'specific runner' do + let(:build) { execute(specific_runner) } - it { expect(build).to be_kind_of(Build) } - it { expect(build).to be_valid } - it { expect(build).to be_running } - it { expect(build.runner).to eq(specific_runner) } + it { expect(build).to be_kind_of(Build) } + it { expect(build).to be_valid } + it { expect(build).to be_running } + it { expect(build.runner).to eq(specific_runner) } + end end - end - context 'disallow when builds are disabled' do - before do - project.update!(shared_runners_enabled: true, group_runners_enabled: true) - project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED) - end + context 'disallow when builds are disabled' do + before do + project.update!(shared_runners_enabled: true, group_runners_enabled: true) + project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED) + end - context 'and uses shared runner' do - let(:build) { execute(shared_runner) } + context 'and uses shared runner' do + let(:build) { execute(shared_runner) } - it { expect(build).to be_nil } - end + it { expect(build).to be_nil } + end - context 'and uses group runner' do - let(:build) { execute(group_runner) } + context 'and uses group runner' do + let(:build) { execute(group_runner) } - it { expect(build).to be_nil } - end + it { expect(build).to be_nil } + end - context 'and uses project runner' do - let(:build) { execute(specific_runner) } + context 'and uses project runner' do + let(:build) { execute(specific_runner) } - it { expect(build).to be_nil } + it { expect(build).to be_nil } + end end - end - context 'allow group runners' do - before do - project.update!(group_runners_enabled: true) - end + context 'allow group runners' do + before do + project.update!(group_runners_enabled: true) + end - context 'for multiple builds' do - let!(:project2) { create(:project, group_runners_enabled: true, group: group) } - let!(:pipeline2) { create(:ci_pipeline, project: project2) } - let!(:project3) { create(:project, group_runners_enabled: true, group: group) } - let!(:pipeline3) { create(:ci_pipeline, project: project3) } + context 'for multiple builds' do + let!(:project2) { create(:project, group_runners_enabled: true, group: group) } + let!(:pipeline2) { create(:ci_pipeline, project: project2) } + let!(:project3) { create(:project, group_runners_enabled: true, group: group) } + let!(:pipeline3) { create(:ci_pipeline, project: project3) } - let!(:build1_project1) { pending_job } - let!(:build2_project1) { create(:ci_build, pipeline: pipeline) } - let!(:build3_project1) { create(:ci_build, pipeline: pipeline) } - let!(:build1_project2) { create(:ci_build, pipeline: pipeline2) } - let!(:build2_project2) { create(:ci_build, pipeline: pipeline2) } - let!(:build1_project3) { create(:ci_build, pipeline: pipeline3) } + let!(:build1_project1) { pending_job } + let!(:build2_project1) { create(:ci_build, pipeline: pipeline) } + let!(:build3_project1) { create(:ci_build, pipeline: pipeline) } + let!(:build1_project2) { create(:ci_build, pipeline: pipeline2) } + let!(:build2_project2) { create(:ci_build, pipeline: pipeline2) } + let!(:build1_project3) { create(:ci_build, pipeline: pipeline3) } - # these shouldn't influence the scheduling - let!(:unrelated_group) { create(:group) } - let!(:unrelated_project) { create(:project, group_runners_enabled: true, group: unrelated_group) } - let!(:unrelated_pipeline) { create(:ci_pipeline, project: unrelated_project) } - let!(:build1_unrelated_project) { create(:ci_build, pipeline: unrelated_pipeline) } - let!(:unrelated_group_runner) { create(:ci_runner, :group, groups: [unrelated_group]) } + # these shouldn't influence the scheduling + let!(:unrelated_group) { create(:group) } + let!(:unrelated_project) { create(:project, group_runners_enabled: true, group: unrelated_group) } + let!(:unrelated_pipeline) { create(:ci_pipeline, project: unrelated_project) } + let!(:build1_unrelated_project) { create(:ci_build, pipeline: unrelated_pipeline) } + let!(:unrelated_group_runner) { create(:ci_runner, :group, groups: [unrelated_group]) } - it 'does not consider builds from other group runners' do - expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 6 - execute(group_runner) + it 'does not consider builds from other group runners' do + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 6 + execute(group_runner) - expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 5 - execute(group_runner) + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 5 + execute(group_runner) - expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 4 - execute(group_runner) + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 4 + execute(group_runner) - expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 3 - execute(group_runner) + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 3 + execute(group_runner) - expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 2 - execute(group_runner) + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 2 + execute(group_runner) - expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 1 - execute(group_runner) + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 1 + execute(group_runner) - expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 0 - expect(execute(group_runner)).to be_nil + expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 0 + expect(execute(group_runner)).to be_nil + end end - end - context 'group runner' do - let(:build) { execute(group_runner) } + context 'group runner' do + let(:build) { execute(group_runner) } - it { expect(build).to be_kind_of(Build) } - it { expect(build).to be_valid } - it { expect(build).to be_running } - it { expect(build.runner).to eq(group_runner) } + it { expect(build).to be_kind_of(Build) } + it { expect(build).to be_valid } + it { expect(build).to be_running } + it { expect(build.runner).to eq(group_runner) } + end end - end - context 'disallow group runners' do - before do - project.update!(group_runners_enabled: false) - end + context 'disallow group runners' do + before do + project.update!(group_runners_enabled: false) + end - context 'group runner' do - let(:build) { execute(group_runner) } + context 'group runner' do + let(:build) { execute(group_runner) } - it { expect(build).to be_nil } + it { expect(build).to be_nil } + end end - end - context 'when first build is stalled' do - before do - allow_any_instance_of(Ci::RegisterJobService).to receive(:assign_runner!).and_call_original - allow_any_instance_of(Ci::RegisterJobService).to receive(:assign_runner!) - .with(pending_job, anything).and_raise(ActiveRecord::StaleObjectError) - end + context 'when first build is stalled' do + before do + allow_any_instance_of(Ci::RegisterJobService).to receive(:assign_runner!).and_call_original + allow_any_instance_of(Ci::RegisterJobService).to receive(:assign_runner!) + .with(pending_job, anything).and_raise(ActiveRecord::StaleObjectError) + end - subject { described_class.new(specific_runner).execute } + subject { described_class.new(specific_runner).execute } - context 'with multiple builds are in queue' do - let!(:other_build) { create :ci_build, pipeline: pipeline } + context 'with multiple builds are in queue' do + let!(:other_build) { create :ci_build, pipeline: pipeline } - before do - allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner) - .and_return(Ci::Build.where(id: [pending_job, other_build])) - end + before do + allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner) + .and_return(Ci::Build.where(id: [pending_job, other_build])) + end - it "receives second build from the queue" do - expect(subject).to be_valid - expect(subject.build).to eq(other_build) + it "receives second build from the queue" do + expect(subject).to be_valid + expect(subject.build).to eq(other_build) + end end - end - context 'when single build is in queue' do - before do - allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner) - .and_return(Ci::Build.where(id: pending_job)) - end + context 'when single build is in queue' do + before do + allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner) + .and_return(Ci::Build.where(id: pending_job)) + end - it "does not receive any valid result" do - expect(subject).not_to be_valid + it "does not receive any valid result" do + expect(subject).not_to be_valid + end end - end - context 'when there is no build in queue' do - before do - allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner) - .and_return(Ci::Build.none) - end + context 'when there is no build in queue' do + before do + allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner) + .and_return(Ci::Build.none) + end - it "does not receive builds but result is valid" do - expect(subject).to be_valid - expect(subject.build).to be_nil + it "does not receive builds but result is valid" do + expect(subject).to be_valid + expect(subject.build).to be_nil + end end end - end - context 'when access_level of runner is not_protected' do - let!(:specific_runner) { create(:ci_runner, :project, projects: [project]) } + context 'when access_level of runner is not_protected' do + let!(:specific_runner) { create(:ci_runner, :project, projects: [project]) } - context 'when a job is protected' do - let!(:pending_job) { create(:ci_build, :protected, pipeline: pipeline) } + context 'when a job is protected' do + let!(:pending_job) { create(:ci_build, :protected, pipeline: pipeline) } - it 'picks the job' do - expect(execute(specific_runner)).to eq(pending_job) + it 'picks the job' do + expect(execute(specific_runner)).to eq(pending_job) + end end - end - context 'when a job is unprotected' do - let!(:pending_job) { create(:ci_build, pipeline: pipeline) } + context 'when a job is unprotected' do + let!(:pending_job) { create(:ci_build, pipeline: pipeline) } - it 'picks the job' do - expect(execute(specific_runner)).to eq(pending_job) + it 'picks the job' do + expect(execute(specific_runner)).to eq(pending_job) + end end - end - context 'when protected attribute of a job is nil' do - let!(:pending_job) { create(:ci_build, pipeline: pipeline) } + context 'when protected attribute of a job is nil' do + let!(:pending_job) { create(:ci_build, pipeline: pipeline) } - before do - pending_job.update_attribute(:protected, nil) - end + before do + pending_job.update_attribute(:protected, nil) + end - it 'picks the job' do - expect(execute(specific_runner)).to eq(pending_job) + it 'picks the job' do + expect(execute(specific_runner)).to eq(pending_job) + end end end - end - context 'when access_level of runner is ref_protected' do - let!(:specific_runner) { create(:ci_runner, :project, :ref_protected, projects: [project]) } + context 'when access_level of runner is ref_protected' do + let!(:specific_runner) { create(:ci_runner, :project, :ref_protected, projects: [project]) } - context 'when a job is protected' do - let!(:pending_job) { create(:ci_build, :protected, pipeline: pipeline) } + context 'when a job is protected' do + let!(:pending_job) { create(:ci_build, :protected, pipeline: pipeline) } - it 'picks the job' do - expect(execute(specific_runner)).to eq(pending_job) + it 'picks the job' do + expect(execute(specific_runner)).to eq(pending_job) + end end - end - context 'when a job is unprotected' do - let!(:pending_job) { create(:ci_build, pipeline: pipeline) } + context 'when a job is unprotected' do + let!(:pending_job) { create(:ci_build, pipeline: pipeline) } - it 'does not pick the job' do - expect(execute(specific_runner)).to be_nil + it 'does not pick the job' do + expect(execute(specific_runner)).to be_nil + end end - end - context 'when protected attribute of a job is nil' do - let!(:pending_job) { create(:ci_build, pipeline: pipeline) } + context 'when protected attribute of a job is nil' do + let!(:pending_job) { create(:ci_build, pipeline: pipeline) } - before do - pending_job.update_attribute(:protected, nil) - end + before do + pending_job.update_attribute(:protected, nil) + end - it 'does not pick the job' do - expect(execute(specific_runner)).to be_nil + it 'does not pick the job' do + expect(execute(specific_runner)).to be_nil + end end end - end - context 'runner feature set is verified' do - let(:options) { { artifacts: { reports: { junit: "junit.xml" } } } } - let!(:pending_job) { create(:ci_build, :pending, pipeline: pipeline, options: options) } + context 'runner feature set is verified' do + let(:options) { { artifacts: { reports: { junit: "junit.xml" } } } } + let!(:pending_job) { create(:ci_build, :pending, pipeline: pipeline, options: options) } - subject { execute(specific_runner, params) } + subject { execute(specific_runner, params) } - context 'when feature is missing by runner' do - let(:params) { {} } + context 'when feature is missing by runner' do + let(:params) { {} } - it 'does not pick the build and drops the build' do - expect(subject).to be_nil - expect(pending_job.reload).to be_failed - expect(pending_job).to be_runner_unsupported + it 'does not pick the build and drops the build' do + expect(subject).to be_nil + expect(pending_job.reload).to be_failed + expect(pending_job).to be_runner_unsupported + end end - end - context 'when feature is supported by runner' do - let(:params) do - { info: { features: { upload_multiple_artifacts: true } } } - end + context 'when feature is supported by runner' do + let(:params) do + { info: { features: { upload_multiple_artifacts: true } } } + end - it 'does pick job' do - expect(subject).not_to be_nil + it 'does pick job' do + expect(subject).not_to be_nil + end end end - end - context 'when "dependencies" keyword is specified' do - shared_examples 'not pick' do - it 'does not pick the build and drops the build' do - expect(subject).to be_nil - expect(pending_job.reload).to be_failed - expect(pending_job).to be_missing_dependency_failure + context 'when "dependencies" keyword is specified' do + shared_examples 'not pick' do + it 'does not pick the build and drops the build' do + expect(subject).to be_nil + expect(pending_job.reload).to be_failed + expect(pending_job).to be_missing_dependency_failure + end end - end - shared_examples 'validation is active' do - context 'when depended job has not been completed yet' do - let!(:pre_stage_job) { create(:ci_build, :manual, pipeline: pipeline, name: 'test', stage_idx: 0) } + shared_examples 'validation is active' do + context 'when depended job has not been completed yet' do + let!(:pre_stage_job) { create(:ci_build, :manual, pipeline: pipeline, name: 'test', stage_idx: 0) } - it { expect(subject).to eq(pending_job) } - end + it { expect(subject).to eq(pending_job) } + end - context 'when artifacts of depended job has been expired' do - let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) } + context 'when artifacts of depended job has been expired' do + let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) } - it_behaves_like 'not pick' - end + it_behaves_like 'not pick' + end - context 'when artifacts of depended job has been erased' do - let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago) } + context 'when artifacts of depended job has been erased' do + let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago) } - before do - pre_stage_job.erase + before do + pre_stage_job.erase + end + + it_behaves_like 'not pick' end - it_behaves_like 'not pick' + context 'when job object is staled' do + let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) } + + before do + allow_any_instance_of(Ci::Build).to receive(:drop!) + .and_raise(ActiveRecord::StaleObjectError.new(pending_job, :drop!)) + end + + it 'does not drop nor pick' do + expect(subject).to be_nil + end + end end - context 'when job object is staled' do - let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) } + shared_examples 'validation is not active' do + context 'when depended job has not been completed yet' do + let!(:pre_stage_job) { create(:ci_build, :manual, pipeline: pipeline, name: 'test', stage_idx: 0) } - before do - allow_any_instance_of(Ci::Build).to receive(:drop!) - .and_raise(ActiveRecord::StaleObjectError.new(pending_job, :drop!)) + it { expect(subject).to eq(pending_job) } end - it 'does not drop nor pick' do - expect(subject).to be_nil + context 'when artifacts of depended job has been expired' do + let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) } + + it { expect(subject).to eq(pending_job) } end - end - end - shared_examples 'validation is not active' do - context 'when depended job has not been completed yet' do - let!(:pre_stage_job) { create(:ci_build, :manual, pipeline: pipeline, name: 'test', stage_idx: 0) } + context 'when artifacts of depended job has been erased' do + let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago) } - it { expect(subject).to eq(pending_job) } + before do + pre_stage_job.erase + end + + it { expect(subject).to eq(pending_job) } + end end - context 'when artifacts of depended job has been expired' do - let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) } + before do + stub_feature_flags(ci_validate_build_dependencies_override: false) + end + + let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0) } - it { expect(subject).to eq(pending_job) } + let!(:pending_job) do + create(:ci_build, :pending, + pipeline: pipeline, stage_idx: 1, + options: { script: ["bash"], dependencies: ['test'] }) end - context 'when artifacts of depended job has been erased' do - let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago) } + subject { execute(specific_runner) } + context 'when validates for dependencies is enabled' do before do - pre_stage_job.erase + stub_feature_flags(ci_validate_build_dependencies_override: false) end - it { expect(subject).to eq(pending_job) } + it_behaves_like 'validation is active' + + context 'when the main feature flag is enabled for a specific project' do + before do + stub_feature_flags(ci_validate_build_dependencies: pipeline.project) + end + + it_behaves_like 'validation is active' + end + + context 'when the main feature flag is enabled for a different project' do + before do + stub_feature_flags(ci_validate_build_dependencies: create(:project)) + end + + it_behaves_like 'validation is not active' + end end - end - before do - stub_feature_flags(ci_validate_build_dependencies_override: false) + context 'when validates for dependencies is disabled' do + before do + stub_feature_flags(ci_validate_build_dependencies_override: true) + end + + it_behaves_like 'validation is not active' + end end - let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0) } + context 'when build is degenerated' do + let!(:pending_job) { create(:ci_build, :pending, :degenerated, pipeline: pipeline) } + + subject { execute(specific_runner, {}) } + + it 'does not pick the build and drops the build' do + expect(subject).to be_nil - let!(:pending_job) do - create(:ci_build, :pending, - pipeline: pipeline, stage_idx: 1, - options: { script: ["bash"], dependencies: ['test'] }) + pending_job.reload + expect(pending_job).to be_failed + expect(pending_job).to be_archived_failure + end end - subject { execute(specific_runner) } + context 'when build has data integrity problem' do + let!(:pending_job) do + create(:ci_build, :pending, pipeline: pipeline) + end - context 'when validates for dependencies is enabled' do before do - stub_feature_flags(ci_validate_build_dependencies_override: false) + pending_job.update_columns(options: "string") end - it_behaves_like 'validation is active' + subject { execute(specific_runner, {}) } - context 'when the main feature flag is enabled for a specific project' do - before do - stub_feature_flags(ci_validate_build_dependencies: pipeline.project) - end + it 'does drop the build and logs both failures' do + expect(Gitlab::ErrorTracking).to receive(:track_exception) + .with(anything, a_hash_including(build_id: pending_job.id)) + .twice + .and_call_original - it_behaves_like 'validation is active' - end - - context 'when the main feature flag is enabled for a different project' do - before do - stub_feature_flags(ci_validate_build_dependencies: create(:project)) - end + expect(subject).to be_nil - it_behaves_like 'validation is not active' + pending_job.reload + expect(pending_job).to be_failed + expect(pending_job).to be_data_integrity_failure end end - context 'when validates for dependencies is disabled' do + context 'when build fails to be run!' do + let!(:pending_job) do + create(:ci_build, :pending, pipeline: pipeline) + end + before do - stub_feature_flags(ci_validate_build_dependencies_override: true) + expect_any_instance_of(Ci::Build).to receive(:run!) + .and_raise(RuntimeError, 'scheduler error') end - it_behaves_like 'validation is not active' + subject { execute(specific_runner, {}) } + + it 'does drop the build and logs failure' do + expect(Gitlab::ErrorTracking).to receive(:track_exception) + .with(anything, a_hash_including(build_id: pending_job.id)) + .once + .and_call_original + + expect(subject).to be_nil + + pending_job.reload + expect(pending_job).to be_failed + expect(pending_job).to be_scheduler_failure + end end - end - context 'when build is degenerated' do - let!(:pending_job) { create(:ci_build, :pending, :degenerated, pipeline: pipeline) } + context 'when an exception is raised during a persistent ref creation' do + before do + allow_any_instance_of(Ci::PersistentRef).to receive(:exist?) { false } + allow_any_instance_of(Ci::PersistentRef).to receive(:create_ref) { raise ArgumentError } + end - subject { execute(specific_runner, {}) } + subject { execute(specific_runner, {}) } - it 'does not pick the build and drops the build' do - expect(subject).to be_nil + it 'picks the build' do + expect(subject).to eq(pending_job) - pending_job.reload - expect(pending_job).to be_failed - expect(pending_job).to be_archived_failure + pending_job.reload + expect(pending_job).to be_running + end end - end - context 'when build has data integrity problem' do - let!(:pending_job) do - create(:ci_build, :pending, pipeline: pipeline) - end + context 'when only some builds can be matched by runner' do + let!(:specific_runner) { create(:ci_runner, :project, projects: [project], tag_list: %w[matching]) } + let!(:pending_job) { create(:ci_build, pipeline: pipeline, tag_list: %w[matching]) } - before do - pending_job.update_columns(options: "string") + before do + # create additional matching and non-matching jobs + create_list(:ci_build, 2, pipeline: pipeline, tag_list: %w[matching]) + create(:ci_build, pipeline: pipeline, tag_list: %w[non-matching]) + end + + it "observes queue size of only matching jobs" do + # pending_job + 2 x matching ones + expect(Gitlab::Ci::Queue::Metrics.queue_size_total).to receive(:observe).with({}, 3) + + expect(execute(specific_runner)).to eq(pending_job) + end end - subject { execute(specific_runner, {}) } + context 'when ci_register_job_temporary_lock is enabled' do + before do + stub_feature_flags(ci_register_job_temporary_lock: true) - it 'does drop the build and logs both failures' do - expect(Gitlab::ErrorTracking).to receive(:track_exception) - .with(anything, a_hash_including(build_id: pending_job.id)) - .twice - .and_call_original + allow(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment) + end - expect(subject).to be_nil + context 'when a build is temporarily locked' do + let(:service) { described_class.new(specific_runner) } - pending_job.reload - expect(pending_job).to be_failed - expect(pending_job).to be_data_integrity_failure - end - end + before do + service.send(:acquire_temporary_lock, pending_job.id) + end - context 'when build fails to be run!' do - let!(:pending_job) do - create(:ci_build, :pending, pipeline: pipeline) - end + it 'skips this build and marks queue as invalid' do + expect(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment) + .with(operation: :queue_iteration) + expect(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment) + .with(operation: :build_temporary_locked) - before do - expect_any_instance_of(Ci::Build).to receive(:run!) - .and_raise(RuntimeError, 'scheduler error') - end + expect(service.execute).not_to be_valid + end - subject { execute(specific_runner, {}) } + context 'when there is another build in queue' do + let!(:next_pending_job) { create(:ci_build, pipeline: pipeline) } - it 'does drop the build and logs failure' do - expect(Gitlab::ErrorTracking).to receive(:track_exception) - .with(anything, a_hash_including(build_id: pending_job.id)) - .once - .and_call_original + it 'skips this build and picks another build' do + expect(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment) + .with(operation: :queue_iteration).twice + expect(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment) + .with(operation: :build_temporary_locked) - expect(subject).to be_nil + result = service.execute - pending_job.reload - expect(pending_job).to be_failed - expect(pending_job).to be_scheduler_failure + expect(result.build).to eq(next_pending_job) + expect(result).to be_valid + end + end + end end end - context 'when an exception is raised during a persistent ref creation' do + context 'when ci_register_job_service_one_by_one is enabled' do before do - allow_any_instance_of(Ci::PersistentRef).to receive(:exist?) { false } - allow_any_instance_of(Ci::PersistentRef).to receive(:create_ref) { raise ArgumentError } + stub_feature_flags(ci_register_job_service_one_by_one: true) end - subject { execute(specific_runner, {}) } + it 'picks builds one-by-one' do + expect(Ci::Build).to receive(:find).with(pending_job.id).and_call_original - it 'picks the build' do - expect(subject).to eq(pending_job) + expect(execute(specific_runner)).to eq(pending_job) + end + + include_examples 'handles runner assignment' + end - pending_job.reload - expect(pending_job).to be_running + context 'when ci_register_job_service_one_by_one is disabled' do + before do + stub_feature_flags(ci_register_job_service_one_by_one: false) end + + include_examples 'handles runner assignment' end end @@ -590,22 +673,14 @@ module Ci before do allow(Time).to receive(:now).and_return(current_time) - - # Stub defaults for any metrics other than the ones we're testing - allow(Gitlab::Metrics).to receive(:counter) - .with(any_args) - .and_return(Gitlab::Metrics::NullMetric.instance) - allow(Gitlab::Metrics).to receive(:histogram) - .with(any_args) - .and_return(Gitlab::Metrics::NullMetric.instance) - # Stub tested metrics - allow(Gitlab::Metrics).to receive(:counter) - .with(:job_register_attempts_total, anything) - .and_return(attempt_counter) - allow(Gitlab::Metrics).to receive(:histogram) - .with(:job_queue_duration_seconds, anything, anything, anything) - .and_return(job_queue_duration_seconds) + allow(Gitlab::Ci::Queue::Metrics) + .to receive(:attempt_counter) + .and_return(attempt_counter) + + allow(Gitlab::Ci::Queue::Metrics) + .to receive(:job_queue_duration_seconds) + .and_return(job_queue_duration_seconds) project.update!(shared_runners_enabled: true) pending_job.update!(created_at: current_time - 3600, queued_at: current_time - 1800) @@ -655,7 +730,7 @@ module Ci context 'when shared runner is used' do let(:runner) { create(:ci_runner, :instance, tag_list: %w(tag1 tag2)) } let(:expected_shared_runner) { true } - let(:expected_shard) { Ci::RegisterJobService::DEFAULT_METRICS_SHARD } + let(:expected_shard) { ::Gitlab::Ci::Queue::Metrics::DEFAULT_METRICS_SHARD } let(:expected_jobs_running_for_project_first_job) { 0 } let(:expected_jobs_running_for_project_third_job) { 2 } @@ -694,7 +769,7 @@ module Ci context 'when specific runner is used' do let(:runner) { create(:ci_runner, :project, projects: [project], tag_list: %w(tag1 metrics_shard::shard_tag tag2)) } let(:expected_shared_runner) { false } - let(:expected_shard) { Ci::RegisterJobService::DEFAULT_METRICS_SHARD } + let(:expected_shard) { ::Gitlab::Ci::Queue::Metrics::DEFAULT_METRICS_SHARD } let(:expected_jobs_running_for_project_first_job) { '+Inf' } let(:expected_jobs_running_for_project_third_job) { '+Inf' } @@ -715,6 +790,46 @@ module Ci end end + context 'when max queue depth is reached' do + let!(:pending_job) { create(:ci_build, :pending, :degenerated, pipeline: pipeline) } + let!(:pending_job_2) { create(:ci_build, :pending, :degenerated, pipeline: pipeline) } + let!(:pending_job_3) { create(:ci_build, :pending, pipeline: pipeline) } + + before do + stub_const("#{described_class}::MAX_QUEUE_DEPTH", 2) + end + + context 'when feature is enabled' do + before do + stub_feature_flags(gitlab_ci_builds_queue_limit: true) + end + + it 'returns 409 conflict' do + expect(Ci::Build.pending.unstarted.count).to eq 3 + + result = described_class.new(specific_runner).execute + + expect(result).not_to be_valid + expect(result.build).to be_nil + end + end + + context 'when feature is disabled' do + before do + stub_feature_flags(gitlab_ci_builds_queue_limit: false) + end + + it 'returns a valid result' do + expect(Ci::Build.pending.unstarted.count).to eq 3 + + result = described_class.new(specific_runner).execute + + expect(result).to be_valid + expect(result.build).to eq pending_job_3 + end + end + end + def execute(runner, params = {}) described_class.new(runner).execute(params).build end diff --git a/spec/services/ci/update_build_queue_service_spec.rb b/spec/services/ci/update_build_queue_service_spec.rb index ebccfdc5140..2d9f80a249d 100644 --- a/spec/services/ci/update_build_queue_service_spec.rb +++ b/spec/services/ci/update_build_queue_service_spec.rb @@ -26,6 +26,25 @@ RSpec.describe Ci::UpdateBuildQueueService do end it_behaves_like 'refreshes runner' + + it 'avoids running redundant queries' do + expect(Ci::Runner).not_to receive(:owned_or_instance_wide) + + subject.execute(build) + end + + context 'when feature flag ci_reduce_queries_when_ticking_runner_queue is disabled' do + before do + stub_feature_flags(ci_reduce_queries_when_ticking_runner_queue: false) + stub_feature_flags(ci_runners_short_circuit_assignable_for: false) + end + + it 'runs redundant queries using `owned_or_instance_wide` scope' do + expect(Ci::Runner).to receive(:owned_or_instance_wide).and_call_original + + subject.execute(build) + end + end end end @@ -97,4 +116,43 @@ RSpec.describe Ci::UpdateBuildQueueService do it_behaves_like 'does not refresh runner' end end + + context 'avoids N+1 queries', :request_store do + let!(:build) { create(:ci_build, pipeline: pipeline, tag_list: %w[a b]) } + let!(:project_runner) { create(:ci_runner, :project, :online, projects: [project], tag_list: %w[a b c]) } + + context 'when ci_preload_runner_tags and ci_reduce_queries_when_ticking_runner_queue are enabled' do + before do + stub_feature_flags( + ci_reduce_queries_when_ticking_runner_queue: true, + ci_preload_runner_tags: true + ) + end + + it 'does execute the same amount of queries regardless of number of runners' do + control_count = ActiveRecord::QueryRecorder.new { subject.execute(build) }.count + + create_list(:ci_runner, 10, :project, :online, projects: [project], tag_list: %w[b c d]) + + expect { subject.execute(build) }.not_to exceed_all_query_limit(control_count) + end + end + + context 'when ci_preload_runner_tags and ci_reduce_queries_when_ticking_runner_queue are disabled' do + before do + stub_feature_flags( + ci_reduce_queries_when_ticking_runner_queue: false, + ci_preload_runner_tags: false + ) + end + + it 'does execute more queries for more runners' do + control_count = ActiveRecord::QueryRecorder.new { subject.execute(build) }.count + + create_list(:ci_runner, 10, :project, :online, projects: [project], tag_list: %w[b c d]) + + expect { subject.execute(build) }.to exceed_all_query_limit(control_count) + end + end + end end diff --git a/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb b/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb index 90956e7b4ea..98963f57341 100644 --- a/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb +++ b/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb @@ -39,6 +39,8 @@ RSpec.describe Clusters::Kubernetes::CreateOrUpdateNamespaceService, '#execute' stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_KNATIVE_SERVING_ROLE_BINDING_NAME, namespace: namespace) stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_NAME, namespace: namespace) stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME, namespace: namespace) + stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_CILIUM_ROLE_NAME, namespace: namespace) + stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_CILIUM_ROLE_BINDING_NAME, namespace: namespace) stub_kubeclient_get_secret( api_url, diff --git a/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb b/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb index a4f018aec0c..11045dfe950 100644 --- a/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb +++ b/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb @@ -147,6 +147,8 @@ RSpec.describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_KNATIVE_SERVING_ROLE_BINDING_NAME, namespace: namespace) stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_NAME, namespace: namespace) stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME, namespace: namespace) + stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_CILIUM_ROLE_NAME, namespace: namespace) + stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_CILIUM_ROLE_BINDING_NAME, namespace: namespace) end it 'creates a namespace object' do @@ -243,6 +245,47 @@ RSpec.describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do ) ) end + + it 'creates a role granting cilium permissions to the service account' do + subject + + expect(WebMock).to have_requested(:put, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/roles/#{Clusters::Kubernetes::GITLAB_CILIUM_ROLE_NAME}").with( + body: hash_including( + metadata: { + name: Clusters::Kubernetes::GITLAB_CILIUM_ROLE_NAME, + namespace: namespace + }, + rules: [{ + apiGroups: %w(cilium.io), + resources: %w(ciliumnetworkpolicies), + verbs: %w(get list create update patch) + }] + ) + ) + end + + it 'creates a role binding granting cilium permissions to the service account' do + subject + + expect(WebMock).to have_requested(:put, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/rolebindings/#{Clusters::Kubernetes::GITLAB_CILIUM_ROLE_BINDING_NAME}").with( + body: hash_including( + metadata: { + name: Clusters::Kubernetes::GITLAB_CILIUM_ROLE_BINDING_NAME, + namespace: namespace + }, + roleRef: { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'Role', + name: Clusters::Kubernetes::GITLAB_CILIUM_ROLE_NAME + }, + subjects: [{ + kind: 'ServiceAccount', + name: service_account_name, + namespace: namespace + }] + ) + ) + end end end end diff --git a/spec/services/dependency_proxy/find_or_create_manifest_service_spec.rb b/spec/services/dependency_proxy/find_or_create_manifest_service_spec.rb index c375e5a2fa3..40a2f954786 100644 --- a/spec/services/dependency_proxy/find_or_create_manifest_service_spec.rb +++ b/spec/services/dependency_proxy/find_or_create_manifest_service_spec.rb @@ -10,7 +10,12 @@ RSpec.describe DependencyProxy::FindOrCreateManifestService do let(:manifest) { dependency_proxy_manifest.file.read } let(:group) { dependency_proxy_manifest.group } let(:token) { Digest::SHA256.hexdigest('123') } - let(:headers) { { 'docker-content-digest' => dependency_proxy_manifest.digest } } + let(:headers) do + { + 'docker-content-digest' => dependency_proxy_manifest.digest, + 'content-type' => dependency_proxy_manifest.content_type + } + end describe '#execute' do subject { described_class.new(group, image, tag, token).execute } @@ -18,22 +23,37 @@ RSpec.describe DependencyProxy::FindOrCreateManifestService do context 'when no manifest exists' do let_it_be(:image) { 'new-image' } - before do - stub_manifest_head(image, tag, digest: dependency_proxy_manifest.digest) - stub_manifest_download(image, tag, headers: headers) + shared_examples 'downloading the manifest' do + it 'downloads manifest from remote registry if there is no cached one', :aggregate_failures do + expect { subject }.to change { group.dependency_proxy_manifests.count }.by(1) + expect(subject[:status]).to eq(:success) + expect(subject[:manifest]).to be_a(DependencyProxy::Manifest) + expect(subject[:manifest]).to be_persisted + end end - it 'downloads manifest from remote registry if there is no cached one', :aggregate_failures do - expect { subject }.to change { group.dependency_proxy_manifests.count }.by(1) - expect(subject[:status]).to eq(:success) - expect(subject[:manifest]).to be_a(DependencyProxy::Manifest) - expect(subject[:manifest]).to be_persisted + context 'successful head request' do + before do + stub_manifest_head(image, tag, headers: headers) + stub_manifest_download(image, tag, headers: headers) + end + + it_behaves_like 'downloading the manifest' + end + + context 'failed head request' do + before do + stub_manifest_head(image, tag, status: :error) + stub_manifest_download(image, tag, headers: headers) + end + + it_behaves_like 'downloading the manifest' end end context 'when manifest exists' do before do - stub_manifest_head(image, tag, digest: dependency_proxy_manifest.digest) + stub_manifest_head(image, tag, headers: headers) end shared_examples 'using the cached manifest' do @@ -48,15 +68,17 @@ RSpec.describe DependencyProxy::FindOrCreateManifestService do context 'when digest is stale' do let(:digest) { 'new-digest' } + let(:content_type) { 'new-content-type' } before do - stub_manifest_head(image, tag, digest: digest) - stub_manifest_download(image, tag, headers: { 'docker-content-digest' => digest }) + stub_manifest_head(image, tag, headers: { 'docker-content-digest' => digest, 'content-type' => content_type }) + stub_manifest_download(image, tag, headers: { 'docker-content-digest' => digest, 'content-type' => content_type }) end it 'downloads the new manifest and updates the existing record', :aggregate_failures do expect(subject[:status]).to eq(:success) expect(subject[:manifest]).to eq(dependency_proxy_manifest) + expect(subject[:manifest].content_type).to eq(content_type) expect(subject[:manifest].digest).to eq(digest) end end diff --git a/spec/services/dependency_proxy/head_manifest_service_spec.rb b/spec/services/dependency_proxy/head_manifest_service_spec.rb index 7c7ebe4d181..9c1e4d650f8 100644 --- a/spec/services/dependency_proxy/head_manifest_service_spec.rb +++ b/spec/services/dependency_proxy/head_manifest_service_spec.rb @@ -8,12 +8,19 @@ RSpec.describe DependencyProxy::HeadManifestService do let(:tag) { 'latest' } let(:token) { Digest::SHA256.hexdigest('123') } let(:digest) { '12345' } + let(:content_type) { 'foo' } + let(:headers) do + { + 'docker-content-digest' => digest, + 'content-type' => content_type + } + end subject { described_class.new(image, tag, token).execute } context 'remote request is successful' do before do - stub_manifest_head(image, tag, digest: digest) + stub_manifest_head(image, tag, headers: headers) end it { expect(subject[:status]).to eq(:success) } diff --git a/spec/services/dependency_proxy/pull_manifest_service_spec.rb b/spec/services/dependency_proxy/pull_manifest_service_spec.rb index b760839d1fb..b3053174cc0 100644 --- a/spec/services/dependency_proxy/pull_manifest_service_spec.rb +++ b/spec/services/dependency_proxy/pull_manifest_service_spec.rb @@ -9,7 +9,10 @@ RSpec.describe DependencyProxy::PullManifestService do let(:token) { Digest::SHA256.hexdigest('123') } let(:manifest) { { foo: 'bar' }.to_json } let(:digest) { '12345' } - let(:headers) { { 'docker-content-digest' => digest } } + let(:content_type) { 'foo' } + let(:headers) do + { 'docker-content-digest' => digest, 'content-type' => content_type } + end subject { described_class.new(image, tag, token).execute_with_manifest(&method(:check_response)) } @@ -25,6 +28,7 @@ RSpec.describe DependencyProxy::PullManifestService do expect(response[:status]).to eq(:success) expect(response[:file].read).to eq(manifest) expect(response[:digest]).to eq(digest) + expect(response[:content_type]).to eq(content_type) end subject diff --git a/spec/services/deployments/update_environment_service_spec.rb b/spec/services/deployments/update_environment_service_spec.rb index 92488c62315..372805cc0fd 100644 --- a/spec/services/deployments/update_environment_service_spec.rb +++ b/spec/services/deployments/update_environment_service_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Deployments::UpdateEnvironmentService do let(:user) { create(:user) } let(:project) { create(:project, :repository) } - let(:options) { { name: 'production' } } + let(:options) { { name: environment_name } } let(:pipeline) do create( :ci_pipeline, @@ -20,13 +20,14 @@ RSpec.describe Deployments::UpdateEnvironmentService do pipeline: pipeline, ref: 'master', tag: false, - environment: 'production', + environment: environment_name, options: { environment: options }, project: project) end let(:deployment) { job.deployment } let(:environment) { deployment.environment } + let(:environment_name) { 'production' } subject(:service) { described_class.new(deployment) } @@ -131,6 +132,56 @@ RSpec.describe Deployments::UpdateEnvironmentService do end end end + + context 'when deployment tier is specified' do + let(:environment_name) { 'customer-portal' } + let(:options) { { name: environment_name, deployment_tier: 'production' } } + + context 'when tier has already been set' do + before do + environment.update_column(:tier, Environment.tiers[:other]) + end + + it 'overwrites the guessed tier by the specified deployment tier' do + expect { subject.execute } + .to change { environment.reset.tier }.from('other').to('production') + end + end + + context 'when tier has not been set' do + before do + environment.update_column(:tier, nil) + end + + it 'sets the specified deployment tier' do + expect { subject.execute } + .to change { environment.reset.tier }.from(nil).to('production') + end + + context 'when deployment was created by an external CD system' do + before do + deployment.update_column(:deployable_id, nil) + end + + it 'guesses the deployment tier' do + expect { subject.execute } + .to change { environment.reset.tier }.from(nil).to('other') + end + end + end + end + + context 'when deployment tier is not specified' do + let(:environment_name) { 'customer-portal' } + let(:options) { { name: environment_name } } + + it 'guesses the deployment tier' do + environment.update_column(:tier, nil) + + expect { subject.execute } + .to change { environment.reset.tier }.from(nil).to('other') + end + end end describe '#expanded_environment_url' do diff --git a/spec/services/environments/schedule_to_delete_review_apps_service_spec.rb b/spec/services/environments/schedule_to_delete_review_apps_service_spec.rb new file mode 100644 index 00000000000..401d6203b2c --- /dev/null +++ b/spec/services/environments/schedule_to_delete_review_apps_service_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Environments::ScheduleToDeleteReviewAppsService do + include ExclusiveLeaseHelpers + + let_it_be(:maintainer) { create(:user) } + let_it_be(:developer) { create(:user) } + let_it_be(:reporter) { create(:user) } + let_it_be(:project) { create(:project, :private, :repository, namespace: maintainer.namespace) } + + let(:service) { described_class.new(project, current_user, before: 30.days.ago, dry_run: dry_run) } + let(:dry_run) { false } + let(:current_user) { maintainer } + + before do + project.add_maintainer(maintainer) + project.add_developer(developer) + project.add_reporter(reporter) + end + + describe "#execute" do + subject { service.execute } + + shared_examples "can schedule for deletion" do + let!(:old_stopped_review_env) { create(:environment, :with_review_app, :stopped, created_at: 31.days.ago, project: project) } + let!(:new_stopped_review_env) { create(:environment, :with_review_app, :stopped, project: project) } + let!(:old_active_review_env) { create(:environment, :with_review_app, :available, created_at: 31.days.ago, project: project) } + let!(:old_stopped_other_env) { create(:environment, :stopped, created_at: 31.days.ago, project: project) } + let!(:new_stopped_other_env) { create(:environment, :stopped, project: project) } + let!(:old_active_other_env) { create(:environment, :available, created_at: 31.days.ago, project: project) } + let!(:already_deleting_env) { create(:environment, :with_review_app, :stopped, created_at: 31.days.ago, project: project, auto_delete_at: 1.day.from_now) } + let(:already_deleting_time) { already_deleting_env.reload.auto_delete_at } + + context "live run" do + let(:dry_run) { false } + + around do |example| + freeze_time { example.run } + end + + it "marks the correct environment as scheduled_entries" do + expect(subject.success?).to be_truthy + expect(subject.scheduled_entries).to contain_exactly(old_stopped_review_env) + expect(subject.unprocessable_entries).to be_empty + + old_stopped_review_env.reload + new_stopped_review_env.reload + old_active_review_env.reload + old_stopped_other_env.reload + new_stopped_other_env.reload + old_active_other_env.reload + already_deleting_env.reload + + expect(old_stopped_review_env.auto_delete_at).to eq(1.week.from_now) + expect(new_stopped_review_env.auto_delete_at).to be_nil + expect(old_active_review_env.auto_delete_at).to be_nil + expect(old_stopped_other_env.auto_delete_at).to be_nil + expect(new_stopped_other_env.auto_delete_at).to be_nil + expect(old_active_other_env.auto_delete_at).to be_nil + expect(already_deleting_env.auto_delete_at).to eq(already_deleting_time) + end + end + + context "dry run" do + let(:dry_run) { true } + + it "returns the same but doesn't update the record" do + expect(subject.success?).to be_truthy + expect(subject.scheduled_entries).to contain_exactly(old_stopped_review_env) + expect(subject.unprocessable_entries).to be_empty + + old_stopped_review_env.reload + new_stopped_review_env.reload + old_active_review_env.reload + old_stopped_other_env.reload + new_stopped_other_env.reload + old_active_other_env.reload + already_deleting_env.reload + + expect(old_stopped_review_env.auto_delete_at).to be_nil + expect(new_stopped_review_env.auto_delete_at).to be_nil + expect(old_active_review_env.auto_delete_at).to be_nil + expect(old_stopped_other_env.auto_delete_at).to be_nil + expect(new_stopped_other_env.auto_delete_at).to be_nil + expect(old_active_other_env.auto_delete_at).to be_nil + expect(already_deleting_env.auto_delete_at).to eq(already_deleting_time) + end + end + + describe "execution in parallel" do + before do + stub_exclusive_lease_taken(service.send(:key)) + end + + it "does not execute unsafe_mark_scheduled_entries_environments" do + expect(service).not_to receive(:unsafe_mark_scheduled_entries_environments) + + expect(subject.success?).to be_falsey + expect(subject.status).to eq(:conflict) + end + end + end + + context "as a maintainer" do + let(:current_user) { maintainer } + + it_behaves_like "can schedule for deletion" + end + + context "as a developer" do + let(:current_user) { developer } + + it_behaves_like "can schedule for deletion" + end + + context "as a reporter" do + let(:current_user) { reporter } + + it "fails to delete environments" do + old_stopped_review_env = create(:environment, :with_review_app, :stopped, created_at: 31.days.ago, project: project) + + expect(subject.success?).to be_falsey + + # Both of these should be empty as we fail before testing them + expect(subject.scheduled_entries).to be_empty + expect(subject.unprocessable_entries).to be_empty + + old_stopped_review_env.reload + + expect(old_stopped_review_env.auto_delete_at).to be_nil + end + end + end +end diff --git a/spec/services/groups/destroy_service_spec.rb b/spec/services/groups/destroy_service_spec.rb index 2f9bb72939a..a5fce315d91 100644 --- a/spec/services/groups/destroy_service_spec.rb +++ b/spec/services/groups/destroy_service_spec.rb @@ -229,10 +229,10 @@ RSpec.describe Groups::DestroyService do # will still be executed for the nested group as they fall under the same hierarchy # and hence we need to account for this scenario. expect(UserProjectAccessChangedService) - .to receive(:new).with(shared_with_group.user_ids_for_project_authorizations).and_call_original + .to receive(:new).with(shared_with_group.users_ids_of_direct_members).and_call_original expect(UserProjectAccessChangedService) - .not_to receive(:new).with(shared_group.user_ids_for_project_authorizations) + .not_to receive(:new).with(shared_group.users_ids_of_direct_members) destroy_group(shared_group, user, false) end @@ -246,7 +246,7 @@ RSpec.describe Groups::DestroyService do it 'makes use of a specific service to update project authorizations' do expect(UserProjectAccessChangedService) - .to receive(:new).with(shared_with_group.user_ids_for_project_authorizations).and_call_original + .to receive(:new).with(shared_with_group.users_ids_of_direct_members).and_call_original destroy_group(shared_with_group, user, false) end diff --git a/spec/services/groups/group_links/create_service_spec.rb b/spec/services/groups/group_links/create_service_spec.rb index fb88433d8f6..df994b9f2a3 100644 --- a/spec/services/groups/group_links/create_service_spec.rb +++ b/spec/services/groups/group_links/create_service_spec.rb @@ -74,46 +74,56 @@ RSpec.describe Groups::GroupLinks::CreateService, '#execute' do end end - context 'group hierarchies' do + context 'project authorizations based on group hierarchies' do before do group_parent.add_owner(parent_group_user) group.add_owner(group_user) group_child.add_owner(child_group_user) end - context 'group user' do - let(:user) { group_user } + context 'project authorizations refresh' do + it 'is executed only for the direct members of the group' do + expect(UserProjectAccessChangedService).to receive(:new).with(contain_exactly(group_user.id)).and_call_original - it 'create proper authorizations' do subject.execute(shared_group) - - expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey - expect(Ability.allowed?(user, :read_project, project)).to be_truthy - expect(Ability.allowed?(user, :read_project, project_child)).to be_truthy end end - context 'parent group user' do - let(:user) { parent_group_user } + context 'project authorizations' do + context 'group user' do + let(:user) { group_user } - it 'create proper authorizations' do - subject.execute(shared_group) + it 'create proper authorizations' do + subject.execute(shared_group) - expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey - expect(Ability.allowed?(user, :read_project, project)).to be_falsey - expect(Ability.allowed?(user, :read_project, project_child)).to be_falsey + expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey + expect(Ability.allowed?(user, :read_project, project)).to be_truthy + expect(Ability.allowed?(user, :read_project, project_child)).to be_truthy + end end - end - context 'child group user' do - let(:user) { child_group_user } + context 'parent group user' do + let(:user) { parent_group_user } - it 'create proper authorizations' do - subject.execute(shared_group) + it 'create proper authorizations' do + subject.execute(shared_group) + + expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey + expect(Ability.allowed?(user, :read_project, project)).to be_falsey + expect(Ability.allowed?(user, :read_project, project_child)).to be_falsey + end + end + + context 'child group user' do + let(:user) { child_group_user } + + it 'create proper authorizations' do + subject.execute(shared_group) - expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey - expect(Ability.allowed?(user, :read_project, project)).to be_falsey - expect(Ability.allowed?(user, :read_project, project_child)).to be_falsey + expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey + expect(Ability.allowed?(user, :read_project, project)).to be_falsey + expect(Ability.allowed?(user, :read_project, project_child)).to be_falsey + end end end end diff --git a/spec/services/groups/group_links/destroy_service_spec.rb b/spec/services/groups/group_links/destroy_service_spec.rb index 22fe8a1d58b..97fe23e9147 100644 --- a/spec/services/groups/group_links/destroy_service_spec.rb +++ b/spec/services/groups/group_links/destroy_service_spec.rb @@ -47,8 +47,8 @@ RSpec.describe Groups::GroupLinks::DestroyService, '#execute' do it 'updates project authorization once per group' do expect(GroupGroupLink).to receive(:delete).and_call_original - expect(group).to receive(:refresh_members_authorized_projects).once - expect(another_group).to receive(:refresh_members_authorized_projects).once + expect(group).to receive(:refresh_members_authorized_projects).with(direct_members_only: true).once + expect(another_group).to receive(:refresh_members_authorized_projects).with(direct_members_only: true).once subject.execute(links) end diff --git a/spec/services/groups/group_links/update_service_spec.rb b/spec/services/groups/group_links/update_service_spec.rb index e4ff83d7926..436cdf89a0f 100644 --- a/spec/services/groups/group_links/update_service_spec.rb +++ b/spec/services/groups/group_links/update_service_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Groups::GroupLinks::UpdateService, '#execute' do let_it_be(:group) { create(:group, :private) } let_it_be(:shared_group) { create(:group, :private) } let_it_be(:project) { create(:project, group: shared_group) } - let(:group_member) { create(:user) } + let(:group_member_user) { create(:user) } let!(:link) { create(:group_group_link, shared_group: shared_group, shared_with_group: group) } let(:expiry_date) { 1.month.from_now.to_date } @@ -20,7 +20,7 @@ RSpec.describe Groups::GroupLinks::UpdateService, '#execute' do subject { described_class.new(link).execute(group_link_params) } before do - group.add_developer(group_member) + group.add_developer(group_member_user) end it 'updates existing link' do @@ -36,11 +36,11 @@ RSpec.describe Groups::GroupLinks::UpdateService, '#execute' do end it 'updates project permissions' do - expect { subject }.to change { group_member.can?(:create_release, project) }.from(true).to(false) + expect { subject }.to change { group_member_user.can?(:create_release, project) }.from(true).to(false) end it 'executes UserProjectAccessChangedService' do - expect_next_instance_of(UserProjectAccessChangedService) do |service| + expect_next_instance_of(UserProjectAccessChangedService, [group_member_user.id]) do |service| expect(service).to receive(:execute) end diff --git a/spec/services/groups/import_export/import_service_spec.rb b/spec/services/groups/import_export/import_service_spec.rb index 0c7765dcd38..ad5c4364deb 100644 --- a/spec/services/groups/import_export/import_service_spec.rb +++ b/spec/services/groups/import_export/import_service_spec.rb @@ -54,7 +54,7 @@ RSpec.describe Groups::ImportExport::ImportService do end context 'with group_import_ndjson feature flag disabled' do - let(:user) { create(:admin) } + let(:user) { create(:user) } let(:group) { create(:group) } let(:import_logger) { instance_double(Gitlab::Import::Logger) } @@ -63,6 +63,8 @@ RSpec.describe Groups::ImportExport::ImportService do before do stub_feature_flags(group_import_ndjson: false) + group.add_owner(user) + ImportExportUpload.create!(group: group, import_file: import_file) allow(Gitlab::Import::Logger).to receive(:build).and_return(import_logger) @@ -95,7 +97,7 @@ RSpec.describe Groups::ImportExport::ImportService do end context 'when importing a ndjson export' do - let(:user) { create(:admin) } + let(:user) { create(:user) } let(:group) { create(:group) } let(:service) { described_class.new(group: group, user: user) } let(:import_file) { fixture_file_upload('spec/fixtures/group_export.tar.gz') } @@ -115,6 +117,10 @@ RSpec.describe Groups::ImportExport::ImportService do end context 'when user has correct permissions' do + before do + group.add_owner(user) + end + it 'imports group structure successfully' do expect(subject).to be_truthy end @@ -147,8 +153,6 @@ RSpec.describe Groups::ImportExport::ImportService do end context 'when user does not have correct permissions' do - let(:user) { create(:user) } - it 'logs the error and raises an exception' do expect(import_logger).to receive(:error).with( group_id: group.id, @@ -188,6 +192,10 @@ RSpec.describe Groups::ImportExport::ImportService do context 'when there are errors with the sub-relations' do let(:import_file) { fixture_file_upload('spec/fixtures/group_export_invalid_subrelations.tar.gz') } + before do + group.add_owner(user) + end + it 'successfully imports the group' do expect(subject).to be_truthy end @@ -207,7 +215,7 @@ RSpec.describe Groups::ImportExport::ImportService do end context 'when importing a json export' do - let(:user) { create(:admin) } + let(:user) { create(:user) } let(:group) { create(:group) } let(:service) { described_class.new(group: group, user: user) } let(:import_file) { fixture_file_upload('spec/fixtures/legacy_group_export.tar.gz') } @@ -227,6 +235,10 @@ RSpec.describe Groups::ImportExport::ImportService do end context 'when user has correct permissions' do + before do + group.add_owner(user) + end + it 'imports group structure successfully' do expect(subject).to be_truthy end @@ -259,8 +271,6 @@ RSpec.describe Groups::ImportExport::ImportService do end context 'when user does not have correct permissions' do - let(:user) { create(:user) } - it 'logs the error and raises an exception' do expect(import_logger).to receive(:error).with( group_id: group.id, @@ -300,6 +310,10 @@ RSpec.describe Groups::ImportExport::ImportService do context 'when there are errors with the sub-relations' do let(:import_file) { fixture_file_upload('spec/fixtures/legacy_group_export_invalid_subrelations.tar.gz') } + before do + group.add_owner(user) + end + it 'successfully imports the group' do expect(subject).to be_truthy end diff --git a/spec/services/import/github_service_spec.rb b/spec/services/import/github_service_spec.rb index 408d7767254..776df01d399 100644 --- a/spec/services/import/github_service_spec.rb +++ b/spec/services/import/github_service_spec.rb @@ -54,6 +54,62 @@ RSpec.describe Import::GithubService do expect { subject.execute(access_params, :github) }.to raise_error(exception) end + + context 'repository size validation' do + let(:repository_double) { double(name: 'repository', size: 99) } + + before do + expect(client).to receive(:repository).and_return(repository_double) + + allow_next_instance_of(Gitlab::LegacyGithubImport::ProjectCreator) do |creator| + allow(creator).to receive(:execute).and_return(double(persisted?: true)) + end + end + + context 'when there is no repository size limit defined' do + it 'skips the check and succeeds' do + expect(subject.execute(access_params, :github)).to include(status: :success) + end + end + + context 'when the target namespace repository size limit is defined' do + let_it_be(:group) { create(:group, repository_size_limit: 100) } + + before do + params[:target_namespace] = group.full_path + end + + it 'succeeds when the repository is smaller than the limit' do + expect(subject.execute(access_params, :github)).to include(status: :success) + end + + it 'returns error when the repository is larger than the limit' do + allow(repository_double).to receive(:size).and_return(101) + + expect(subject.execute(access_params, :github)).to include(size_limit_error) + end + end + + context 'when target namespace repository limit is not defined' do + let_it_be(:group) { create(:group) } + + before do + stub_application_setting(repository_size_limit: 100) + end + + context 'when application size limit is defined' do + it 'succeeds when the repository is smaller than the limit' do + expect(subject.execute(access_params, :github)).to include(status: :success) + end + + it 'returns error when the repository is larger than the limit' do + allow(repository_double).to receive(:size).and_return(101) + + expect(subject.execute(access_params, :github)).to include(size_limit_error) + end + end + end + end end context 'when remove_legacy_github_client feature flag is enabled' do @@ -71,4 +127,12 @@ RSpec.describe Import::GithubService do include_examples 'handles errors', Gitlab::LegacyGithubImport::Client end + + def size_limit_error + { + status: :error, + http_status: :unprocessable_entity, + message: '"repository" size (101 Bytes) is larger than the limit of 100 Bytes.' + } + end end diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb index 79543fe9f5d..c749f282cd3 100644 --- a/spec/services/issuable/bulk_update_service_spec.rb +++ b/spec/services/issuable/bulk_update_service_spec.rb @@ -31,23 +31,6 @@ RSpec.describe Issuable::BulkUpdateService do end end - shared_examples 'updates iterations' do - it 'succeeds' do - result = bulk_update(issuables, sprint_id: iteration.id) - - expect(result.success?).to be_truthy - expect(result.payload[:count]).to eq(issuables.count) - end - - it 'updates the issuables iteration' do - bulk_update(issuables, sprint_id: iteration.id) - - issuables.each do |issuable| - expect(issuable.reload.iteration).to eq(iteration) - end - end - end - shared_examples 'updating labels' do def create_issue_with_labels(labels) create(:labeled_issue, project: project, labels: labels) @@ -250,21 +233,6 @@ RSpec.describe Issuable::BulkUpdateService do it_behaves_like 'updates milestones' end - describe 'updating iterations' do - let_it_be(:group) { create(:group) } - let_it_be(:project) { create(:project, group: group) } - let_it_be(:issuables) { [create(:issue, project: project)] } - let_it_be(:iteration) { create(:iteration, group: group) } - - let(:parent) { project } - - before do - group.add_reporter(user) - end - - it_behaves_like 'updates iterations' - end - describe 'updating labels' do let(:bug) { create(:label, project: project) } let(:regression) { create(:label, project: project) } @@ -347,19 +315,6 @@ RSpec.describe Issuable::BulkUpdateService do end end - describe 'updating iterations' do - let_it_be(:iteration) { create(:iteration, group: group) } - let_it_be(:project) { create(:project, :repository, group: group) } - - context 'when issues' do - let_it_be(:issue1) { create(:issue, project: project) } - let_it_be(:issue2) { create(:issue, project: project) } - let_it_be(:issuables) { [issue1, issue2] } - - it_behaves_like 'updates iterations' - end - end - describe 'updating labels' do let(:project) { create(:project, :repository, group: group) } let(:bug) { create(:group_label, group: group) } diff --git a/spec/services/issuable/process_assignees_spec.rb b/spec/services/issuable/process_assignees_spec.rb new file mode 100644 index 00000000000..876c84957cc --- /dev/null +++ b/spec/services/issuable/process_assignees_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Issuable::ProcessAssignees do + describe '#execute' do + it 'returns assignee_ids when assignee_ids are specified' do + process = Issuable::ProcessAssignees.new(assignee_ids: %w(5 7 9), + add_assignee_ids: %w(2 4 6), + remove_assignee_ids: %w(4 7 11), + existing_assignee_ids: %w(1 3 9), + extra_assignee_ids: %w(2 5 12)) + result = process.execute + + expect(result.sort).to eq(%w(5 7 9).sort) + end + + it 'combines other ids when assignee_ids is empty' do + process = Issuable::ProcessAssignees.new(assignee_ids: [], + add_assignee_ids: %w(2 4 6), + remove_assignee_ids: %w(4 7 11), + existing_assignee_ids: %w(1 3 11), + extra_assignee_ids: %w(2 5 12)) + result = process.execute + + expect(result.sort).to eq(%w(1 2 3 5 6 12).sort) + end + + it 'combines other ids when assignee_ids is nil' do + process = Issuable::ProcessAssignees.new(assignee_ids: nil, + add_assignee_ids: %w(2 4 6), + remove_assignee_ids: %w(4 7 11), + existing_assignee_ids: %w(1 3 11), + extra_assignee_ids: %w(2 5 12)) + result = process.execute + + expect(result.sort).to eq(%w(1 2 3 5 6 12).sort) + end + + it 'combines other ids when assignee_ids and add_assignee_ids are nil' do + process = Issuable::ProcessAssignees.new(assignee_ids: nil, + add_assignee_ids: nil, + remove_assignee_ids: %w(4 7 11), + existing_assignee_ids: %w(1 3 11), + extra_assignee_ids: %w(2 5 12)) + result = process.execute + + expect(result.sort).to eq(%w(1 2 3 5 12).sort) + end + + it 'combines other ids when assignee_ids and remove_assignee_ids are nil' do + process = Issuable::ProcessAssignees.new(assignee_ids: nil, + add_assignee_ids: %w(2 4 6), + remove_assignee_ids: nil, + existing_assignee_ids: %w(1 3 11), + extra_assignee_ids: %w(2 5 12)) + result = process.execute + + expect(result.sort).to eq(%w(1 2 4 3 5 6 11 12).sort) + end + + it 'combines ids when only add_assignee_ids and remove_assignee_ids are passed' do + process = Issuable::ProcessAssignees.new(assignee_ids: nil, + add_assignee_ids: %w(2 4 6), + remove_assignee_ids: %w(4 7 11)) + result = process.execute + + expect(result.sort).to eq(%w(2 6).sort) + end + end +end diff --git a/spec/services/issues/clone_service_spec.rb b/spec/services/issues/clone_service_spec.rb index 512a60b1382..9ceb4ffeec5 100644 --- a/spec/services/issues/clone_service_spec.rb +++ b/spec/services/issues/clone_service_spec.rb @@ -280,6 +280,12 @@ RSpec.describe Issues::CloneService do expect(new_issue.designs.first.notes.size).to eq(1) end end + + context 'issue relative position' do + let(:subject) { clone_service.execute(old_issue, new_project) } + + it_behaves_like 'copy or reset relative position' + end end describe 'clone permissions' do diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index e42e9722297..d548e5ee74a 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -286,6 +286,12 @@ RSpec.describe Issues::CreateService do issue end + + it 'schedules a namespace onboarding create action worker' do + expect(Namespaces::OnboardingIssueCreatedWorker).to receive(:perform_async).with(project.namespace.id) + + issue + end end context 'issue create service' do diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index 9b8d21bb8eb..eb124f07900 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -244,6 +244,12 @@ RSpec.describe Issues::MoveService do expect(new_issue.designs.first.notes.size).to eq(1) end end + + context 'issue relative position' do + let(:subject) { move_service.execute(old_issue, new_project) } + + it_behaves_like 'copy or reset relative position' + end end describe 'move permissions' do diff --git a/spec/services/jira_import/users_importer_spec.rb b/spec/services/jira_import/users_importer_spec.rb index 7112443502c..c825f899f80 100644 --- a/spec/services/jira_import/users_importer_spec.rb +++ b/spec/services/jira_import/users_importer_spec.rb @@ -54,8 +54,11 @@ RSpec.describe JiraImport::UsersImporter do end context 'when jira client raises an error' do + let(:error) { Timeout::Error.new } + it 'returns an error response' do - expect(client).to receive(:get).and_raise(Timeout::Error) + expect(client).to receive(:get).and_raise(error) + expect(Gitlab::ErrorTracking).to receive(:log_exception).with(error, project_id: project.id) expect(subject.error?).to be_truthy expect(subject.message).to include('There was an error when communicating to Jira') diff --git a/spec/services/labels/promote_service_spec.rb b/spec/services/labels/promote_service_spec.rb index 15d53857f33..81c24b26c9f 100644 --- a/spec/services/labels/promote_service_spec.rb +++ b/spec/services/labels/promote_service_spec.rb @@ -4,9 +4,9 @@ require 'spec_helper' RSpec.describe Labels::PromoteService do describe '#execute' do - let!(:user) { create(:user) } + let_it_be(:user) { create(:user) } - context 'project without group' do + context 'without a group' do let!(:project_1) { create(:project) } let!(:project_label_1_1) { create(:label, project: project_1) } @@ -18,40 +18,40 @@ RSpec.describe Labels::PromoteService do end end - context 'project with group' do - let!(:promoted_label_name) { "Promoted Label" } - let!(:untouched_label_name) { "Untouched Label" } - let!(:promoted_description) { "Promoted Description" } - let!(:promoted_color) { "#0000FF" } - let!(:label_2_1_priority) { 1 } - let!(:label_3_1_priority) { 2 } + context 'with a group' do + let_it_be(:promoted_label_name) { "Promoted Label" } + let_it_be(:untouched_label_name) { "Untouched Label" } + let_it_be(:promoted_description) { "Promoted Description" } + let_it_be(:promoted_color) { "#0000FF" } + let_it_be(:label_2_1_priority) { 1 } + let_it_be(:label_3_1_priority) { 2 } - let!(:group_1) { create(:group) } - let!(:group_2) { create(:group) } + let_it_be(:group_1) { create(:group) } + let_it_be(:group_2) { create(:group) } - let!(:project_1) { create(:project, namespace: group_1) } - let!(:project_2) { create(:project, namespace: group_1) } - let!(:project_3) { create(:project, namespace: group_1) } - let!(:project_4) { create(:project, namespace: group_2) } + let_it_be(:project_1) { create(:project, :repository, namespace: group_1) } + let_it_be(:project_2) { create(:project, :repository, namespace: group_1) } + let_it_be(:project_3) { create(:project, :repository, namespace: group_1) } + let_it_be(:project_4) { create(:project, :repository, namespace: group_2) } # Labels/issues can't be lazily created so we might as well eager initialize # all other objects too since we use them inside - let!(:project_label_1_1) { create(:label, project: project_1, name: promoted_label_name, color: promoted_color, description: promoted_description) } - let!(:project_label_1_2) { create(:label, project: project_1, name: untouched_label_name) } - let!(:project_label_2_1) { create(:label, project: project_2, priority: label_2_1_priority, name: promoted_label_name, color: "#FF0000") } - let!(:project_label_3_1) { create(:label, project: project_3, priority: label_3_1_priority, name: promoted_label_name) } - let!(:project_label_3_2) { create(:label, project: project_3, priority: 1, name: untouched_label_name) } - let!(:project_label_4_1) { create(:label, project: project_4, name: promoted_label_name) } + let_it_be(:project_label_1_1) { create(:label, project: project_1, name: promoted_label_name, color: promoted_color, description: promoted_description) } + let_it_be(:project_label_1_2) { create(:label, project: project_1, name: untouched_label_name) } + let_it_be(:project_label_2_1) { create(:label, project: project_2, priority: label_2_1_priority, name: promoted_label_name, color: "#FF0000") } + let_it_be(:project_label_3_1) { create(:label, project: project_3, priority: label_3_1_priority, name: promoted_label_name) } + let_it_be(:project_label_3_2) { create(:label, project: project_3, priority: 1, name: untouched_label_name) } + let_it_be(:project_label_4_1) { create(:label, project: project_4, name: promoted_label_name) } - let!(:issue_1_1) { create(:labeled_issue, project: project_1, labels: [project_label_1_1, project_label_1_2]) } - let!(:issue_1_2) { create(:labeled_issue, project: project_1, labels: [project_label_1_2]) } - let!(:issue_2_1) { create(:labeled_issue, project: project_2, labels: [project_label_2_1]) } - let!(:issue_4_1) { create(:labeled_issue, project: project_4, labels: [project_label_4_1]) } + let_it_be(:issue_1_1) { create(:labeled_issue, project: project_1, labels: [project_label_1_1, project_label_1_2]) } + let_it_be(:issue_1_2) { create(:labeled_issue, project: project_1, labels: [project_label_1_2]) } + let_it_be(:issue_2_1) { create(:labeled_issue, project: project_2, labels: [project_label_2_1]) } + let_it_be(:issue_4_1) { create(:labeled_issue, project: project_4, labels: [project_label_4_1]) } - let!(:merge_3_1) { create(:labeled_merge_request, source_project: project_3, target_project: project_3, labels: [project_label_3_1, project_label_3_2]) } + let_it_be(:merge_3_1) { create(:labeled_merge_request, source_project: project_3, target_project: project_3, labels: [project_label_3_1, project_label_3_2]) } - let!(:issue_board_2_1) { create(:board, project: project_2) } - let!(:issue_board_list_2_1) { create(:list, board: issue_board_2_1, label: project_label_2_1) } + let_it_be(:issue_board_2_1) { create(:board, project: project_2) } + let_it_be(:issue_board_list_2_1) { create(:list, board: issue_board_2_1, label: project_label_2_1) } let(:new_label) { group_1.labels.find_by(title: promoted_label_name) } @@ -82,8 +82,8 @@ RSpec.describe Labels::PromoteService do expect { service.execute(project_label_1_1) }.to change { Subscription.count }.from(4).to(3) - expect(new_label.subscribed?(user)).to be_truthy - expect(new_label.subscribed?(user2)).to be_truthy + expect(new_label).to be_subscribed(user) + expect(new_label).to be_subscribed(user2) end it 'recreates priorities' do @@ -165,12 +165,12 @@ RSpec.describe Labels::PromoteService do service.execute(project_label_1_1) Label.reflect_on_all_associations.each do |association| - expect(project_label_1_1.send(association.name).any?).to be_falsey + expect(project_label_1_1.send(association.name).reset).not_to be_any end end end - context 'if there is an existing identical group label' do + context 'when there is an existing identical group label' do let!(:existing_group_label) { create(:group_label, group: group_1, title: project_label_1_1.title ) } it 'uses the existing group label' do @@ -187,7 +187,7 @@ RSpec.describe Labels::PromoteService do it_behaves_like 'promoting a project label to a group label' end - context 'if there is no existing identical group label' do + context 'when there is no existing identical group label' do let(:existing_group_label) { nil } it 'recreates the label as a group label' do diff --git a/spec/services/members/invite_service_spec.rb b/spec/services/members/invite_service_spec.rb index 08cdf0d3ae1..cced93896a5 100644 --- a/spec/services/members/invite_service_spec.rb +++ b/spec/services/members/invite_service_spec.rb @@ -2,76 +2,155 @@ require 'spec_helper' -RSpec.describe Members::InviteService do - let(:project) { create(:project) } - let(:user) { create(:user) } - let(:project_user) { create(:user) } - - before do - project.add_maintainer(user) +RSpec.describe Members::InviteService, :aggregate_failures do + let_it_be(:project) { create(:project) } + let_it_be(:user) { project.owner } + let_it_be(:project_user) { create(:user) } + let(:params) { {} } + let(:base_params) { { access_level: Gitlab::Access::GUEST } } + + subject(:result) { described_class.new(user, base_params.merge(params)).execute(project) } + + context 'when email is previously unused by current members' do + let(:params) { { email: 'email@example.org' } } + + it 'successfully creates a member' do + expect { result }.to change(ProjectMember, :count).by(1) + expect(result[:status]).to eq(:success) + end end - it 'adds an existing user to members' do - params = { email: project_user.email.to_s, access_level: Gitlab::Access::GUEST } - result = described_class.new(user, params).execute(project) + context 'when emails are passed as an array' do + let(:params) { { email: %w[email@example.org email2@example.org] } } - expect(result[:status]).to eq(:success) - expect(project.users).to include project_user + it 'successfully creates members' do + expect { result }.to change(ProjectMember, :count).by(2) + expect(result[:status]).to eq(:success) + end end - it 'creates a new user for an unknown email address' do - params = { email: 'email@example.org', access_level: Gitlab::Access::GUEST } - result = described_class.new(user, params).execute(project) + context 'when emails are passed as an empty string' do + let(:params) { { email: '' } } - expect(result[:status]).to eq(:success) + it 'returns an error' do + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq('Email cannot be blank') + end end - it 'limits the number of emails to 100' do - emails = Array.new(101).map { |n| "email#{n}@example.com" } - params = { email: emails, access_level: Gitlab::Access::GUEST } + context 'when email param is not included' do + it 'returns an error' do + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq('Email cannot be blank') + end + end - result = described_class.new(user, params).execute(project) + context 'when email is not a valid email' do + let(:params) { { email: '_bogus_' } } - expect(result[:status]).to eq(:error) - expect(result[:message]).to eq('Too many users specified (limit is 100)') + it 'returns an error' do + expect { result }.not_to change(ProjectMember, :count) + expect(result[:status]).to eq(:error) + expect(result[:message]['_bogus_']).to eq("Invite email is invalid") + end end - it 'does not invite an invalid email' do - params = { email: project_user.id.to_s, access_level: Gitlab::Access::GUEST } - result = described_class.new(user, params).execute(project) + context 'when duplicate email addresses are passed' do + let(:params) { { email: 'email@example.org,email@example.org' } } + + it 'only creates one member per unique address' do + expect { result }.to change(ProjectMember, :count).by(1) + expect(result[:status]).to eq(:success) + end + end - expect(result[:status]).to eq(:error) - expect(result[:message][project_user.id.to_s]).to eq("Invite email is invalid") - expect(project.users).not_to include project_user + context 'when observing email limits' do + let_it_be(:emails) { Array(1..101).map { |n| "email#{n}@example.com" } } + + context 'when over the allowed default limit of emails' do + let(:params) { { email: emails } } + + it 'limits the number of emails to 100' do + expect { result }.not_to change(ProjectMember, :count) + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq('Too many users specified (limit is 100)') + end + end + + context 'when over the allowed custom limit of emails' do + let(:params) { { email: 'email@example.org,email2@example.org', limit: 1 } } + + it 'limits the number of emails to the limit supplied' do + expect { result }.not_to change(ProjectMember, :count) + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq('Too many users specified (limit is 1)') + end + end + + context 'when limit allowed is disabled via limit param' do + let(:params) { { email: emails, limit: -1 } } + + it 'does not limit number of emails' do + expect { result }.to change(ProjectMember, :count).by(101) + expect(result[:status]).to eq(:success) + end + end end - it 'does not invite to an invalid access level' do - params = { email: project_user.email, access_level: -1 } - result = described_class.new(user, params).execute(project) + context 'when email belongs to an existing user' do + let(:params) { { email: project_user.email } } - expect(result[:status]).to eq(:error) - expect(result[:message][project_user.email]).to eq("Access level is not included in the list") + it 'adds an existing user to members' do + expect { result }.to change(ProjectMember, :count).by(1) + expect(result[:status]).to eq(:success) + expect(project.users).to include project_user + end end - it 'does not add a member with an existing invite' do - invited_member = create(:project_member, :invited, project: project) + context 'when access level is not valid' do + let(:params) { { email: project_user.email, access_level: -1 } } - params = { email: invited_member.invite_email, - access_level: Gitlab::Access::GUEST } - result = described_class.new(user, params).execute(project) + it 'returns an error' do + expect { result }.not_to change(ProjectMember, :count) + expect(result[:status]).to eq(:error) + expect(result[:message][project_user.email]).to eq("Access level is not included in the list") + end + end + + context 'when invite already exists for an included email' do + let!(:invited_member) { create(:project_member, :invited, project: project) } + let(:params) { { email: "#{invited_member.invite_email},#{project_user.email}" } } - expect(result[:status]).to eq(:error) - expect(result[:message][invited_member.invite_email]).to eq("Member already invited to #{project.name}") + it 'adds new email and returns an error for the already invited email' do + expect { result }.to change(ProjectMember, :count).by(1) + expect(result[:status]).to eq(:error) + expect(result[:message][invited_member.invite_email]).to eq("Member already invited to #{project.name}") + expect(project.users).to include project_user + end end - it 'does not add a member with an access_request' do - requested_member = create(:project_member, :access_request, project: project) + context 'when access request already exists for an included email' do + let!(:requested_member) { create(:project_member, :access_request, project: project) } + let(:params) { { email: "#{requested_member.user.email},#{project_user.email}" } } + + it 'adds new email and returns an error for the already invited email' do + expect { result }.to change(ProjectMember, :count).by(1) + expect(result[:status]).to eq(:error) + expect(result[:message][requested_member.user.email]) + .to eq("Member cannot be invited because they already requested to join #{project.name}") + expect(project.users).to include project_user + end + end - params = { email: requested_member.user.email, - access_level: Gitlab::Access::GUEST } - result = described_class.new(user, params).execute(project) + context 'when email is already a member on the project' do + let!(:existing_member) { create(:project_member, :guest, project: project) } + let(:params) { { email: "#{existing_member.user.email},#{project_user.email}" } } - expect(result[:status]).to eq(:error) - expect(result[:message][requested_member.user.email]).to eq("Member cannot be invited because they already requested to join #{project.name}") + it 'adds new email and returns an error for the already invited email' do + expect { result }.to change(ProjectMember, :count).by(1) + expect(result[:status]).to eq(:error) + expect(result[:message][existing_member.user.email]).to eq("Already a member of #{project.name}") + expect(project.users).to include project_user + end end end diff --git a/spec/services/merge_requests/after_create_service_spec.rb b/spec/services/merge_requests/after_create_service_spec.rb index f21feb70bc5..dce351d8a31 100644 --- a/spec/services/merge_requests/after_create_service_spec.rb +++ b/spec/services/merge_requests/after_create_service_spec.rb @@ -32,6 +32,10 @@ RSpec.describe MergeRequests::AfterCreateService do .to receive(:track_create_mr_action) .with(user: merge_request.author) + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) + .to receive(:track_mr_including_ci_config) + .with(user: merge_request.author, merge_request: merge_request) + execute_service end @@ -67,5 +71,27 @@ RSpec.describe MergeRequests::AfterCreateService do it_behaves_like 'records an onboarding progress action', :merge_request_created do let(:namespace) { merge_request.target_project.namespace } end + + context 'when merge request is in unchecked state' do + before do + merge_request.mark_as_unchecked! + execute_service + end + + it 'does not change its state' do + expect(merge_request.reload).to be_unchecked + end + end + + context 'when merge request is in preparing state' do + before do + merge_request.mark_as_preparing! + execute_service + end + + it 'marks the merge request as unchecked' do + expect(merge_request.reload).to be_unchecked + end + end end end diff --git a/spec/services/merge_requests/build_service_spec.rb b/spec/services/merge_requests/build_service_spec.rb index 22b3456708f..8adf6d69f73 100644 --- a/spec/services/merge_requests/build_service_spec.rb +++ b/spec/services/merge_requests/build_service_spec.rb @@ -19,8 +19,21 @@ RSpec.describe MergeRequests::BuildService do let(:label_ids) { [] } let(:merge_request) { service.execute } let(:compare) { double(:compare, commits: commits) } - let(:commit_1) { double(:commit_1, sha: 'f00ba7', safe_message: "Initial commit\n\nCreate the app") } - let(:commit_2) { double(:commit_2, sha: 'f00ba7', safe_message: 'This is a bad commit message!') } + let(:commit_1) do + double(:commit_1, sha: 'f00ba6', safe_message: 'Initial commit', + gitaly_commit?: false, id: 'f00ba6', parent_ids: ['f00ba5']) + end + + let(:commit_2) do + double(:commit_2, sha: 'f00ba7', safe_message: "Closes #1234 Second commit\n\nCreate the app", + gitaly_commit?: false, id: 'f00ba7', parent_ids: ['f00ba6']) + end + + let(:commit_3) do + double(:commit_3, sha: 'f00ba8', safe_message: 'This is a bad commit message!', + gitaly_commit?: false, id: 'f00ba8', parent_ids: ['f00ba7']) + end + let(:commits) { nil } let(:params) do @@ -47,6 +60,7 @@ RSpec.describe MergeRequests::BuildService do allow(CompareService).to receive_message_chain(:new, :execute).and_return(compare) allow(project).to receive(:commit).and_return(commit_1) allow(project).to receive(:commit).and_return(commit_2) + allow(project).to receive(:commit).and_return(commit_3) end shared_examples 'allows the merge request to be created' do @@ -137,7 +151,7 @@ RSpec.describe MergeRequests::BuildService do context 'when target branch is missing' do let(:target_branch) { nil } - let(:commits) { Commit.decorate([commit_1], project) } + let(:commits) { Commit.decorate([commit_2], project) } before do stub_compare @@ -199,8 +213,8 @@ RSpec.describe MergeRequests::BuildService do end context 'one commit in the diff' do - let(:commits) { Commit.decorate([commit_1], project) } - let(:commit_description) { commit_1.safe_message.split(/\n+/, 2).last } + let(:commits) { Commit.decorate([commit_2], project) } + let(:commit_description) { commit_2.safe_message.split(/\n+/, 2).last } before do stub_compare @@ -209,7 +223,7 @@ RSpec.describe MergeRequests::BuildService do it_behaves_like 'allows the merge request to be created' it 'uses the title of the commit as the title of the merge request' do - expect(merge_request.title).to eq(commit_1.safe_message.split("\n").first) + expect(merge_request.title).to eq(commit_2.safe_message.split("\n").first) end it 'uses the description of the commit as the description of the merge request' do @@ -225,10 +239,10 @@ RSpec.describe MergeRequests::BuildService do end context 'commit has no description' do - let(:commits) { Commit.decorate([commit_2], project) } + let(:commits) { Commit.decorate([commit_3], project) } it 'uses the title of the commit as the title of the merge request' do - expect(merge_request.title).to eq(commit_2.safe_message) + expect(merge_request.title).to eq(commit_3.safe_message) end it 'sets the description to nil' do @@ -257,7 +271,7 @@ RSpec.describe MergeRequests::BuildService do end it 'uses the title of the commit as the title of the merge request' do - expect(merge_request.title).to eq('Initial commit') + expect(merge_request.title).to eq('Closes #1234 Second commit') end it 'appends the closing description' do @@ -310,8 +324,8 @@ RSpec.describe MergeRequests::BuildService do end end - context 'more than one commit in the diff' do - let(:commits) { Commit.decorate([commit_1, commit_2], project) } + context 'no multi-line commit messages in the diff' do + let(:commits) { Commit.decorate([commit_1, commit_3], project) } before do stub_compare @@ -365,6 +379,55 @@ RSpec.describe MergeRequests::BuildService do end end end + end + + context 'a multi-line commit message in the diff' do + let(:commits) { Commit.decorate([commit_1, commit_2, commit_3], project) } + + before do + stub_compare + end + + it_behaves_like 'allows the merge request to be created' + + it 'uses the first line of the first multi-line commit message as the title' do + expect(merge_request.title).to eq('Closes #1234 Second commit') + end + + it 'adds the remaining lines of the first multi-line commit message as the description' do + expect(merge_request.description).to eq('Create the app') + end + + context 'when the source branch matches an issue' do + where(:issue_tracker, :source_branch, :title, :closing_message) do + :jira | 'FOO-123-fix-issue' | 'Resolve FOO-123 "Fix issue"' | 'Closes FOO-123' + :jira | 'fix-issue' | 'Fix issue' | nil + :custom_issue_tracker | '123-fix-issue' | 'Resolve #123 "Fix issue"' | 'Closes #123' + :custom_issue_tracker | 'fix-issue' | 'Fix issue' | nil + :internal | '123-fix-issue' | 'Resolve "A bug"' | 'Closes #123' + :internal | 'fix-issue' | 'Fix issue' | nil + :internal | '124-fix-issue' | '124 fix issue' | nil + end + + with_them do + before do + if issue_tracker == :internal + issue.update!(iid: 123) + else + create(:"#{issue_tracker}_service", project: project) + project.reload + end + end + + it 'sets the correct title' do + expect(merge_request.title).to eq('Closes #1234 Second commit') + end + + it 'sets the closing description' do + expect(merge_request.description).to eq("Create the app#{closing_message ? "\n\n" + closing_message : ''}") + end + end + end context 'when the issue is not accessible to user' do let(:source_branch) { "#{issue.iid}-fix-issue" } @@ -373,12 +436,12 @@ RSpec.describe MergeRequests::BuildService do project.team.truncate end - it 'uses branch title as the merge request title' do - expect(merge_request.title).to eq("#{issue.iid} fix issue") + it 'uses the first line of the first multi-line commit message as the title' do + expect(merge_request.title).to eq('Closes #1234 Second commit') end - it 'does not set a description' do - expect(merge_request.description).to be_nil + it 'adds the remaining lines of the first multi-line commit message as the description' do + expect(merge_request.description).to eq('Create the app') end end @@ -386,12 +449,12 @@ RSpec.describe MergeRequests::BuildService do let(:source_branch) { "#{issue.iid}-fix-issue" } let(:issue_confidential) { true } - it 'uses the title of the branch as the merge request title' do - expect(merge_request.title).to eq("#{issue.iid} fix issue") + it 'uses the first line of the first multi-line commit message as the title' do + expect(merge_request.title).to eq('Closes #1234 Second commit') end - it 'does not set a description' do - expect(merge_request.description).to be_nil + it 'adds the remaining lines of the first multi-line commit message as the description' do + expect(merge_request.description).to eq('Create the app') end end end @@ -399,7 +462,7 @@ RSpec.describe MergeRequests::BuildService do context 'source branch does not exist' do before do allow(project).to receive(:commit).with(source_branch).and_return(nil) - allow(project).to receive(:commit).with(target_branch).and_return(commit_1) + allow(project).to receive(:commit).with(target_branch).and_return(commit_2) end it_behaves_like 'forbids the merge request from being created' do @@ -409,7 +472,7 @@ RSpec.describe MergeRequests::BuildService do context 'target branch does not exist' do before do - allow(project).to receive(:commit).with(source_branch).and_return(commit_1) + allow(project).to receive(:commit).with(source_branch).and_return(commit_2) allow(project).to receive(:commit).with(target_branch).and_return(nil) end @@ -433,7 +496,7 @@ RSpec.describe MergeRequests::BuildService do context 'upstream project has disabled merge requests' do let(:upstream_project) { create(:project, :merge_requests_disabled) } let(:project) { create(:project, forked_from_project: upstream_project) } - let(:commits) { Commit.decorate([commit_1], project) } + let(:commits) { Commit.decorate([commit_2], project) } it 'sets target project correctly' do expect(merge_request.target_project).to eq(project) @@ -441,8 +504,8 @@ RSpec.describe MergeRequests::BuildService do end context 'target_project is set and accessible by current_user' do - let(:target_project) { create(:project, :public, :repository)} - let(:commits) { Commit.decorate([commit_1], project) } + let(:target_project) { create(:project, :public, :repository) } + let(:commits) { Commit.decorate([commit_2], project) } it 'sets target project correctly' do expect(merge_request.target_project).to eq(target_project) @@ -450,8 +513,8 @@ RSpec.describe MergeRequests::BuildService do end context 'target_project is set but not accessible by current_user' do - let(:target_project) { create(:project, :private, :repository)} - let(:commits) { Commit.decorate([commit_1], project) } + let(:target_project) { create(:project, :private, :repository) } + let(:commits) { Commit.decorate([commit_2], project) } it 'sets target project correctly' do expect(merge_request.target_project).to eq(project) @@ -469,8 +532,8 @@ RSpec.describe MergeRequests::BuildService do end context 'source_project is set and accessible by current_user' do - let(:source_project) { create(:project, :public, :repository)} - let(:commits) { Commit.decorate([commit_1], project) } + let(:source_project) { create(:project, :public, :repository) } + let(:commits) { Commit.decorate([commit_2], project) } before do # To create merge requests _from_ a project the user needs at least @@ -484,8 +547,8 @@ RSpec.describe MergeRequests::BuildService do end context 'source_project is set but not accessible by current_user' do - let(:source_project) { create(:project, :private, :repository)} - let(:commits) { Commit.decorate([commit_1], project) } + let(:source_project) { create(:project, :private, :repository) } + let(:commits) { Commit.decorate([commit_2], project) } it 'sets source project correctly' do expect(merge_request.source_project).to eq(project) diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb index 611f12c8146..87e5750ce6e 100644 --- a/spec/services/merge_requests/merge_service_spec.rb +++ b/spec/services/merge_requests/merge_service_spec.rb @@ -258,9 +258,8 @@ RSpec.describe MergeRequests::MergeService do end it 'removes the source branch using the author user' do - expect(::Branches::DeleteService).to receive(:new) - .with(merge_request.source_project, merge_request.author) - .and_call_original + expect(::MergeRequests::DeleteSourceBranchWorker).to receive(:perform_async).with(merge_request.id, merge_request.source_branch_sha, merge_request.author.id) + service.execute(merge_request) end @@ -268,7 +267,8 @@ RSpec.describe MergeRequests::MergeService do let(:service) { described_class.new(project, user, merge_params.merge('should_remove_source_branch' => false)) } it 'does not delete the source branch' do - expect(::Branches::DeleteService).not_to receive(:new) + expect(::MergeRequests::DeleteSourceBranchWorker).not_to receive(:perform_async) + service.execute(merge_request) end end @@ -280,9 +280,8 @@ RSpec.describe MergeRequests::MergeService do end it 'removes the source branch using the current user' do - expect(::Branches::DeleteService).to receive(:new) - .with(merge_request.source_project, user) - .and_call_original + expect(::MergeRequests::DeleteSourceBranchWorker).to receive(:perform_async).with(merge_request.id, merge_request.source_branch_sha, user.id) + service.execute(merge_request) end end diff --git a/spec/services/merge_requests/post_merge_service_spec.rb b/spec/services/merge_requests/post_merge_service_spec.rb index 71329905558..247b053e729 100644 --- a/spec/services/merge_requests/post_merge_service_spec.rb +++ b/spec/services/merge_requests/post_merge_service_spec.rb @@ -130,139 +130,5 @@ RSpec.describe MergeRequests::PostMergeService do expect(deploy_job.reload.canceled?).to be false end end - - context 'for a merge request chain' do - before do - ::MergeRequests::UpdateService - .new(project, user, force_remove_source_branch: '1') - .execute(merge_request) - end - - context 'when there is another MR' do - let!(:another_merge_request) do - create(:merge_request, - source_project: source_project, - source_branch: 'my-awesome-feature', - target_project: merge_request.source_project, - target_branch: merge_request.source_branch - ) - end - - shared_examples 'does not retarget merge request' do - it 'another merge request is unchanged' do - expect { subject }.not_to change { another_merge_request.reload.target_branch } - .from(merge_request.source_branch) - end - end - - shared_examples 'retargets merge request' do - it 'another merge request is retargeted' do - expect(SystemNoteService) - .to receive(:change_branch).once - .with(another_merge_request, another_merge_request.project, user, - 'target', 'delete', - merge_request.source_branch, merge_request.target_branch) - - expect { subject }.to change { another_merge_request.reload.target_branch } - .from(merge_request.source_branch) - .to(merge_request.target_branch) - end - - context 'when FF retarget_merge_requests is disabled' do - before do - stub_feature_flags(retarget_merge_requests: false) - end - - include_examples 'does not retarget merge request' - end - - context 'when source branch is to be kept' do - before do - ::MergeRequests::UpdateService - .new(project, user, force_remove_source_branch: false) - .execute(merge_request) - end - - include_examples 'does not retarget merge request' - end - end - - context 'in the same project' do - let(:source_project) { project } - - it_behaves_like 'retargets merge request' - - context 'and is closed' do - before do - another_merge_request.close - end - - it_behaves_like 'does not retarget merge request' - end - - context 'and is merged' do - before do - another_merge_request.mark_as_merged - end - - it_behaves_like 'does not retarget merge request' - end - end - - context 'in forked project' do - let!(:source_project) { fork_project(project) } - - context 'when user has access to source project' do - before do - source_project.add_developer(user) - end - - it_behaves_like 'retargets merge request' - end - - context 'when user does not have access to source project' do - it_behaves_like 'does not retarget merge request' - end - end - - context 'and current and another MR is from a fork' do - let(:project) { create(:project) } - let(:source_project) { fork_project(project) } - - let(:merge_request) do - create(:merge_request, - source_project: source_project, - target_project: project - ) - end - - before do - source_project.add_developer(user) - end - - it_behaves_like 'does not retarget merge request' - end - end - - context 'when many merge requests are to be retargeted' do - let!(:many_merge_requests) do - create_list(:merge_request, 10, :unique_branches, - source_project: merge_request.source_project, - target_project: merge_request.source_project, - target_branch: merge_request.source_branch - ) - end - - it 'retargets only 4 of them' do - subject - - expect(many_merge_requests.each(&:reload).pluck(:target_branch).tally) - .to eq( - merge_request.source_branch => 6, - merge_request.target_branch => 4 - ) - end - end - end end end diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index 747ecbf4fa4..2abe7a23bfe 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -72,6 +72,21 @@ RSpec.describe MergeRequests::RefreshService do allow(NotificationService).to receive(:new) { notification_service } end + context 'query count' do + it 'does not execute a lot of queries' do + # Hardcoded the query limit since the queries can also be reduced even + # if there are the same number of merge requests (e.g. by preloading + # associations). This should also fail in case additional queries are + # added elsewhere that affected this service. + # + # The limit is based on the number of queries executed at the current + # state of the service. As we reduce the number of queries executed in + # this service, the limit should be reduced as well. + expect { refresh_service.execute(@oldrev, @newrev, 'refs/heads/master') } + .not_to exceed_query_limit(260) + end + end + it 'executes hooks with update action' do refresh_service.execute(@oldrev, @newrev, 'refs/heads/master') reload_mrs @@ -155,6 +170,18 @@ RSpec.describe MergeRequests::RefreshService do .not_to change { @merge_request.reload.merge_request_diff } end end + + it 'calls the merge request activity counter' do + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) + .to receive(:track_mr_including_ci_config) + .with(user: @merge_request.author, merge_request: @merge_request) + + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) + .to receive(:track_mr_including_ci_config) + .with(user: @another_merge_request.author, merge_request: @another_merge_request) + + refresh_service.execute(@oldrev, @newrev, 'refs/heads/master') + end end context 'when pipeline exists for the source branch' do diff --git a/spec/services/merge_requests/retarget_chain_service_spec.rb b/spec/services/merge_requests/retarget_chain_service_spec.rb new file mode 100644 index 00000000000..3937fbe58c3 --- /dev/null +++ b/spec/services/merge_requests/retarget_chain_service_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequests::RetargetChainService do + include ProjectForksHelper + + let_it_be(:user) { create(:user) } + let_it_be(:merge_request, reload: true) { create(:merge_request, assignees: [user]) } + let_it_be(:project) { merge_request.project } + + subject { described_class.new(project, user).execute(merge_request) } + + before do + project.add_maintainer(user) + end + + describe '#execute' do + context 'when there is another MR' do + let!(:another_merge_request) do + create(:merge_request, + source_project: source_project, + source_branch: 'my-awesome-feature', + target_project: merge_request.source_project, + target_branch: merge_request.source_branch + ) + end + + shared_examples 'does not retarget merge request' do + it 'another merge request is unchanged' do + expect { subject }.not_to change { another_merge_request.reload.target_branch } + .from(merge_request.source_branch) + end + end + + shared_examples 'retargets merge request' do + it 'another merge request is retargeted' do + expect(SystemNoteService) + .to receive(:change_branch).once + .with(another_merge_request, another_merge_request.project, user, + 'target', 'delete', + merge_request.source_branch, merge_request.target_branch) + + expect { subject }.to change { another_merge_request.reload.target_branch } + .from(merge_request.source_branch) + .to(merge_request.target_branch) + end + + context 'when FF retarget_merge_requests is disabled' do + before do + stub_feature_flags(retarget_merge_requests: false) + end + + include_examples 'does not retarget merge request' + end + end + + context 'in the same project' do + let(:source_project) { project } + + context 'and current is merged' do + before do + merge_request.mark_as_merged + end + + it_behaves_like 'retargets merge request' + end + + context 'and current is closed' do + before do + merge_request.close + end + + it_behaves_like 'does not retarget merge request' + end + + context 'and another is closed' do + before do + another_merge_request.close + end + + it_behaves_like 'does not retarget merge request' + end + + context 'and another is merged' do + before do + another_merge_request.mark_as_merged + end + + it_behaves_like 'does not retarget merge request' + end + end + + context 'in forked project' do + let!(:source_project) { fork_project(project) } + + context 'when user has access to source project' do + before do + source_project.add_developer(user) + merge_request.mark_as_merged + end + + it_behaves_like 'retargets merge request' + end + + context 'when user does not have access to source project' do + it_behaves_like 'does not retarget merge request' + end + end + + context 'and current and another MR is from a fork' do + let(:project) { create(:project) } + let(:source_project) { fork_project(project) } + + let(:merge_request) do + create(:merge_request, + source_project: source_project, + target_project: project + ) + end + + before do + source_project.add_developer(user) + end + + it_behaves_like 'does not retarget merge request' + end + end + + context 'when many merge requests are to be retargeted' do + let!(:many_merge_requests) do + create_list(:merge_request, 10, :unique_branches, + source_project: merge_request.source_project, + target_project: merge_request.source_project, + target_branch: merge_request.source_branch + ) + end + + before do + merge_request.mark_as_merged + end + + it 'retargets only 4 of them' do + subject + + expect(many_merge_requests.each(&:reload).pluck(:target_branch).tally) + .to eq( + merge_request.source_branch => 6, + merge_request.target_branch => 4 + ) + end + end + end +end diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index edb95840604..7a7f684c6d0 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -48,6 +48,8 @@ RSpec.describe MergeRequests::UpdateService, :mailer do end context 'valid params' do + let(:locked) { true } + let(:opts) do { title: 'New title', @@ -58,7 +60,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do label_ids: [label.id], target_branch: 'target', force_remove_source_branch: '1', - discussion_locked: true + discussion_locked: locked } end @@ -117,6 +119,139 @@ RSpec.describe MergeRequests::UpdateService, :mailer do MergeRequests::UpdateService.new(project, user, opts).execute(draft_merge_request) end + + context 'when MR is locked' do + context 'when locked again' do + it 'does not track discussion locking' do + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) + .not_to receive(:track_discussion_locked_action) + + opts[:discussion_locked] = true + + MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + end + + context 'when unlocked' do + it 'tracks dicussion unlocking' do + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) + .to receive(:track_discussion_unlocked_action).once.with(user: user) + + opts[:discussion_locked] = false + + MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + end + end + + context 'when MR is unlocked' do + let(:locked) { false } + + context 'when unlocked again' do + it 'does not track discussion unlocking' do + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) + .not_to receive(:track_discussion_unlocked_action) + + opts[:discussion_locked] = false + + MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + end + + context 'when locked' do + it 'tracks dicussion locking' do + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) + .to receive(:track_discussion_locked_action).once.with(user: user) + + opts[:discussion_locked] = true + + MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + end + end + + it 'tracks time estimate and spend time changes' do + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) + .to receive(:track_time_estimate_changed_action).once.with(user: user) + + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) + .to receive(:track_time_spent_changed_action).once.with(user: user) + + opts[:time_estimate] = 86400 + opts[:spend_time] = { + duration: 3600, + user_id: user.id, + spent_at: Date.parse('2021-02-24') + } + + MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + + it 'tracks milestone change' do + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) + .to receive(:track_milestone_changed_action).once.with(user: user) + + opts[:milestone] = milestone + + MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + + it 'track labels change' do + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) + .to receive(:track_labels_changed_action).once.with(user: user) + + opts[:label_ids] = [label2.id] + + MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + + context 'assignees' do + context 'when assignees changed' do + it 'tracks assignees changed event' do + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) + .to receive(:track_assignees_changed_action).once.with(user: user) + + opts[:assignees] = [user2] + + MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + end + + context 'when assignees did not change' do + it 'does not track assignees changed event' do + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) + .not_to receive(:track_assignees_changed_action) + + opts[:assignees] = merge_request.assignees + + MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + end + end + + context 'reviewers' do + context 'when reviewers changed' do + it 'tracks reviewers changed event' do + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) + .to receive(:track_reviewers_changed_action).once.with(user: user) + + opts[:reviewers] = [user2] + + MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + end + + context 'when reviewers did not change' do + it 'does not track reviewers changed event' do + expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter) + .not_to receive(:track_reviewers_changed_action) + + opts[:reviewers] = merge_request.reviewers + + MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + end + end end context 'updating milestone' do @@ -656,6 +791,48 @@ RSpec.describe MergeRequests::UpdateService, :mailer do end end + context 'when the draft status is changed' do + let!(:non_subscriber) { create(:user) } + let!(:subscriber) do + create(:user) { |u| merge_request.toggle_subscription(u, project) } + end + + before do + project.add_developer(non_subscriber) + project.add_developer(subscriber) + end + + context 'removing draft status' do + before do + merge_request.update_attribute(:title, 'Draft: New Title') + end + + it 'sends notifications for subscribers', :sidekiq_might_not_need_inline do + opts = { title: 'New title' } + + perform_enqueued_jobs do + @merge_request = described_class.new(project, user, opts).execute(merge_request) + end + + should_email(subscriber) + should_not_email(non_subscriber) + end + end + + context 'adding draft status' do + it 'does not send notifications', :sidekiq_might_not_need_inline do + opts = { title: 'Draft: New title' } + + perform_enqueued_jobs do + @merge_request = described_class.new(project, user, opts).execute(merge_request) + end + + should_not_email(subscriber) + should_not_email(non_subscriber) + end + end + end + context 'when the merge request is relabeled' do let!(:non_subscriber) { create(:user) } let!(:subscriber) { create(:user) { |u| label.toggle_subscription(u, project) } } diff --git a/spec/services/namespaces/in_product_marketing_emails_service_spec.rb b/spec/services/namespaces/in_product_marketing_emails_service_spec.rb index 7346a5b95ae..28b2e699e5e 100644 --- a/spec/services/namespaces/in_product_marketing_emails_service_spec.rb +++ b/spec/services/namespaces/in_product_marketing_emails_service_spec.rb @@ -3,12 +3,15 @@ require 'spec_helper' RSpec.describe Namespaces::InProductMarketingEmailsService, '#execute' do - subject(:execute_service) { described_class.new(track, interval).execute } + subject(:execute_service) do + travel_to(frozen_time) { described_class.new(track, interval).execute } + end let(:track) { :create } let(:interval) { 1 } - let(:previous_action_completed_at) { 2.days.ago.middle_of_day } + let(:frozen_time) { Time.current } + let(:previous_action_completed_at) { frozen_time - 2.days } let(:current_action_completed_at) { nil } let(:experiment_enabled) { true } let(:user_can_perform_current_track_action) { true } @@ -39,18 +42,18 @@ RSpec.describe Namespaces::InProductMarketingEmailsService, '#execute' do using RSpec::Parameterized::TableSyntax where(:track, :interval, :actions_completed) do - :create | 1 | { created_at: 2.days.ago.middle_of_day } - :create | 5 | { created_at: 6.days.ago.middle_of_day } - :create | 10 | { created_at: 11.days.ago.middle_of_day } - :verify | 1 | { created_at: 2.days.ago.middle_of_day, git_write_at: 2.days.ago.middle_of_day } - :verify | 5 | { created_at: 6.days.ago.middle_of_day, git_write_at: 6.days.ago.middle_of_day } - :verify | 10 | { created_at: 11.days.ago.middle_of_day, git_write_at: 11.days.ago.middle_of_day } - :trial | 1 | { created_at: 2.days.ago.middle_of_day, git_write_at: 2.days.ago.middle_of_day, pipeline_created_at: 2.days.ago.middle_of_day } - :trial | 5 | { created_at: 6.days.ago.middle_of_day, git_write_at: 6.days.ago.middle_of_day, pipeline_created_at: 6.days.ago.middle_of_day } - :trial | 10 | { created_at: 11.days.ago.middle_of_day, git_write_at: 11.days.ago.middle_of_day, pipeline_created_at: 11.days.ago.middle_of_day } - :team | 1 | { created_at: 2.days.ago.middle_of_day, git_write_at: 2.days.ago.middle_of_day, pipeline_created_at: 2.days.ago.middle_of_day, trial_started_at: 2.days.ago.middle_of_day } - :team | 5 | { created_at: 6.days.ago.middle_of_day, git_write_at: 6.days.ago.middle_of_day, pipeline_created_at: 6.days.ago.middle_of_day, trial_started_at: 6.days.ago.middle_of_day } - :team | 10 | { created_at: 11.days.ago.middle_of_day, git_write_at: 11.days.ago.middle_of_day, pipeline_created_at: 11.days.ago.middle_of_day, trial_started_at: 11.days.ago.middle_of_day } + :create | 1 | { created_at: frozen_time - 2.days } + :create | 5 | { created_at: frozen_time - 6.days } + :create | 10 | { created_at: frozen_time - 11.days } + :verify | 1 | { created_at: frozen_time - 2.days, git_write_at: frozen_time - 2.days } + :verify | 5 | { created_at: frozen_time - 6.days, git_write_at: frozen_time - 6.days } + :verify | 10 | { created_at: frozen_time - 11.days, git_write_at: frozen_time - 11.days } + :trial | 1 | { created_at: frozen_time - 2.days, git_write_at: frozen_time - 2.days, pipeline_created_at: frozen_time - 2.days } + :trial | 5 | { created_at: frozen_time - 6.days, git_write_at: frozen_time - 6.days, pipeline_created_at: frozen_time - 6.days } + :trial | 10 | { created_at: frozen_time - 11.days, git_write_at: frozen_time - 11.days, pipeline_created_at: frozen_time - 11.days } + :team | 1 | { created_at: frozen_time - 2.days, git_write_at: frozen_time - 2.days, pipeline_created_at: frozen_time - 2.days, trial_started_at: frozen_time - 2.days } + :team | 5 | { created_at: frozen_time - 6.days, git_write_at: frozen_time - 6.days, pipeline_created_at: frozen_time - 6.days, trial_started_at: frozen_time - 6.days } + :team | 10 | { created_at: frozen_time - 11.days, git_write_at: frozen_time - 11.days, pipeline_created_at: frozen_time - 11.days, trial_started_at: frozen_time - 11.days } end with_them do @@ -64,7 +67,7 @@ RSpec.describe Namespaces::InProductMarketingEmailsService, '#execute' do it { is_expected.not_to send_in_product_marketing_email } context 'when the previous track actions have been completed' do - let(:current_action_completed_at) { 2.days.ago.middle_of_day } + let(:current_action_completed_at) { frozen_time - 2.days } it { is_expected.to send_in_product_marketing_email(user.id, group.id, :verify, 0) } end @@ -76,7 +79,7 @@ RSpec.describe Namespaces::InProductMarketingEmailsService, '#execute' do it { is_expected.not_to send_in_product_marketing_email } context 'when the previous track action was completed within the intervals range' do - let(:previous_action_completed_at) { 6.days.ago.middle_of_day } + let(:previous_action_completed_at) { frozen_time - 6.days } it { is_expected.to send_in_product_marketing_email(user.id, group.id, :create, 1) } end @@ -113,13 +116,13 @@ RSpec.describe Namespaces::InProductMarketingEmailsService, '#execute' do end context 'when the previous track action is completed outside the intervals range' do - let(:previous_action_completed_at) { 3.days.ago } + let(:previous_action_completed_at) { frozen_time - 3.days } it { is_expected.not_to send_in_product_marketing_email } end context 'when the current track action is completed' do - let(:current_action_completed_at) { Time.current } + let(:current_action_completed_at) { frozen_time } it { is_expected.not_to send_in_product_marketing_email } end @@ -156,4 +159,20 @@ RSpec.describe Namespaces::InProductMarketingEmailsService, '#execute' do it { expect { subject }.to raise_error(NotImplementedError, 'No ability defined for track foo') } end + + context 'when group is a sub-group' do + let(:root_group) { create(:group) } + let(:group) { create(:group) } + + before do + group.parent = root_group + group.save! + + allow(Ability).to receive(:allowed?).and_call_original + end + + it 'does not raise an exception' do + expect { execute_service }.not_to raise_error + end + end end diff --git a/spec/services/notes/build_service_spec.rb b/spec/services/notes/build_service_spec.rb index 90548cf9a99..deeab66c4e9 100644 --- a/spec/services/notes/build_service_spec.rb +++ b/spec/services/notes/build_service_spec.rb @@ -3,29 +3,38 @@ require 'spec_helper' RSpec.describe Notes::BuildService do + include AdminModeHelper + let(:note) { create(:discussion_note_on_issue) } let(:project) { note.project } let(:author) { note.author } + let(:user) { author } let(:merge_request) { create(:merge_request, source_project: project) } - let(:mr_note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project, author: author) } + let(:mr_note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project, author: note.author) } + let(:base_params) { { note: 'Test' } } + let(:params) { {} } + + subject(:new_note) { described_class.new(project, user, base_params.merge(params)).execute } describe '#execute' do context 'when in_reply_to_discussion_id is specified' do + let(:params) { { in_reply_to_discussion_id: note.discussion_id } } + context 'when a note with that original discussion ID exists' do it 'sets the note up to be in reply to that note' do - new_note = described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute expect(new_note).to be_valid expect(new_note.in_reply_to?(note)).to be_truthy expect(new_note.resolved?).to be_falsey end context 'when discussion is resolved' do + let(:params) { { in_reply_to_discussion_id: mr_note.discussion_id } } + before do mr_note.resolve!(author) end it 'resolves the note' do - new_note = described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: mr_note.discussion_id).execute expect(new_note).to be_valid expect(new_note.resolved?).to be_truthy end @@ -34,24 +43,23 @@ RSpec.describe Notes::BuildService do context 'when a note with that discussion ID exists' do it 'sets the note up to be in reply to that note' do - new_note = described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute expect(new_note).to be_valid expect(new_note.in_reply_to?(note)).to be_truthy end end context 'when no note with that discussion ID exists' do + let(:params) { { in_reply_to_discussion_id: 'foo' } } + it 'sets an error' do - new_note = described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: 'foo').execute expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found') end end context 'when user has no access to discussion' do - it 'sets an error' do - another_user = create(:user) - new_note = described_class.new(project, another_user, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute + let(:user) { create(:user) } + it 'sets an error' do expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found') end end @@ -127,34 +135,118 @@ RSpec.describe Notes::BuildService do context 'when replying to individual note' do let(:note) { create(:note_on_issue) } - - subject { described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute } + let(:params) { { in_reply_to_discussion_id: note.discussion_id } } it 'sets the note up to be in reply to that note' do - expect(subject).to be_valid - expect(subject).to be_a(DiscussionNote) - expect(subject.discussion_id).to eq(note.discussion_id) + expect(new_note).to be_valid + expect(new_note).to be_a(DiscussionNote) + expect(new_note.discussion_id).to eq(note.discussion_id) end context 'when noteable does not support replies' do let(:note) { create(:note_on_commit) } it 'builds another individual note' do - expect(subject).to be_valid - expect(subject).to be_a(Note) - expect(subject.discussion_id).not_to eq(note.discussion_id) + expect(new_note).to be_valid + expect(new_note).to be_a(Note) + expect(new_note.discussion_id).not_to eq(note.discussion_id) + end + end + end + + context 'confidential comments' do + before do + project.add_reporter(author) + end + + context 'when replying to a confidential comment' do + let(:note) { create(:note_on_issue, confidential: true) } + let(:params) { { in_reply_to_discussion_id: note.discussion_id, confidential: false } } + + context 'when the user can read confidential comments' do + it '`confidential` param is ignored and set to `true`' do + expect(new_note.confidential).to be_truthy + end + end + + context 'when the user cannot read confidential comments' do + let(:user) { create(:user) } + + it 'returns `Discussion to reply to cannot be found` error' do + expect(new_note.errors.first).to include("Discussion to reply to cannot be found") + end + end + end + + context 'when replying to a public comment' do + let(:note) { create(:note_on_issue, confidential: false) } + let(:params) { { in_reply_to_discussion_id: note.discussion_id, confidential: true } } + + it '`confidential` param is ignored and set to `false`' do + expect(new_note.confidential).to be_falsey + end + end + + context 'when creating a new comment' do + context 'when the `confidential` note flag is set to `true`' do + context 'when the user is allowed (reporter)' do + let(:params) { { confidential: true, noteable: merge_request } } + + it 'note `confidential` flag is set to `true`' do + expect(new_note.confidential).to be_truthy + end + end + + context 'when the user is allowed (issuable author)' do + let(:user) { create(:user) } + let(:issue) { create(:issue, author: user) } + let(:params) { { confidential: true, noteable: issue } } + + it 'note `confidential` flag is set to `true`' do + expect(new_note.confidential).to be_truthy + end + end + + context 'when the user is allowed (admin)' do + before do + enable_admin_mode!(admin) + end + + let(:admin) { create(:admin) } + let(:params) { { confidential: true, noteable: merge_request } } + + it 'note `confidential` flag is set to `true`' do + expect(new_note.confidential).to be_truthy + end + end + + context 'when the user is not allowed' do + let(:user) { create(:user) } + let(:params) { { confidential: true, noteable: merge_request } } + + it 'note `confidential` flag is set to `false`' do + expect(new_note.confidential).to be_falsey + end + end + end + + context 'when the `confidential` note flag is set to `false`' do + let(:params) { { confidential: false, noteable: merge_request } } + + it 'note `confidential` flag is set to `false`' do + expect(new_note.confidential).to be_falsey + end end end end - it 'builds a note without saving it' do - new_note = described_class.new(project, - author, - noteable_type: note.noteable_type, - noteable_id: note.noteable_id, - note: 'Test').execute - expect(new_note).to be_valid - expect(new_note).not_to be_persisted + context 'when noteable is not set' do + let(:params) { { noteable_type: note.noteable_type, noteable_id: note.noteable_id } } + + it 'builds a note without saving it' do + expect(new_note).to be_valid + expect(new_note).not_to be_persisted + end end end end diff --git a/spec/services/notes/update_service_spec.rb b/spec/services/notes/update_service_spec.rb index 902fd9958f8..000f3d26efa 100644 --- a/spec/services/notes/update_service_spec.rb +++ b/spec/services/notes/update_service_spec.rb @@ -64,6 +64,40 @@ RSpec.describe Notes::UpdateService do end.to change { counter.unique_events(event_names: event, start_date: 1.day.ago, end_date: 1.day.from_now) }.by(1) end + context 'when note text was changed' do + let!(:note) { create(:note, project: project, noteable: issue, author: user2, note: "Old note #{user3.to_reference}") } + let(:edit_note_text) { update_note({ note: 'new text' }) } + + it 'update last_edited_at' do + travel_to(1.day.from_now) do + expect { edit_note_text }.to change { note.reload.last_edited_at } + end + end + + it 'update updated_by' do + travel_to(1.day.from_now) do + expect { edit_note_text }.to change { note.reload.updated_by } + end + end + end + + context 'when note text was not changed' do + let!(:note) { create(:note, project: project, noteable: issue, author: user2, note: "Old note #{user3.to_reference}") } + let(:does_not_edit_note_text) { update_note({}) } + + it 'does not update last_edited_at' do + travel_to(1.day.from_now) do + expect { does_not_edit_note_text }.not_to change { note.reload.last_edited_at } + end + end + + it 'does not update updated_by' do + travel_to(1.day.from_now) do + expect { does_not_edit_note_text }.not_to change { note.reload.updated_by } + end + end + end + context 'when the notable is a merge request' do let(:merge_request) { create(:merge_request, source_project: project) } let(:note) { create(:note, project: project, noteable: merge_request, author: user, note: "Old note #{user2.to_reference}") } diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index b67c37ba02d..f3cd2776ce7 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -99,6 +99,23 @@ RSpec.describe NotificationService, :mailer do end end + shared_examples 'is not able to send notifications' do + it 'does not send any notification' do + user_1 = create(:user) + recipient_1 = NotificationRecipient.new(user_1, :custom, custom_action: :new_release) + allow(NotificationRecipients::BuildService).to receive(:build_new_release_recipients).and_return([recipient_1]) + + expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: current_user.id, klass: object.class, object_id: object.id) + + action + + should_not_email(@u_mentioned) + should_not_email(@u_guest_watcher) + should_not_email(user_1) + should_not_email(current_user) + end + end + # Next shared examples are intended to test notifications of "participants" # # they take the following parameters: @@ -243,11 +260,12 @@ RSpec.describe NotificationService, :mailer do describe 'AccessToken' do describe '#access_token_about_to_expire' do let_it_be(:user) { create(:user) } + let_it_be(:pat) { create(:personal_access_token, user: user, expires_at: 5.days.from_now) } - subject { notification.access_token_about_to_expire(user) } + subject { notification.access_token_about_to_expire(user, [pat.name]) } it 'sends email to the token owner' do - expect { subject }.to have_enqueued_email(user, mail: "access_token_about_to_expire_email") + expect { subject }.to have_enqueued_email(user, [pat.name], mail: "access_token_about_to_expire_email") end end @@ -297,17 +315,17 @@ RSpec.describe NotificationService, :mailer do describe 'Notes' do context 'issue note' do let_it_be(:project) { create(:project, :private) } - let_it_be(:issue) { create(:issue, project: project, assignees: [assignee]) } + let_it_be_with_reload(:issue) { create(:issue, project: project, assignees: [assignee]) } let_it_be(:mentioned_issue) { create(:issue, assignees: issue.assignees) } let_it_be_with_reload(:author) { create(:user) } let(:note) { create(:note_on_issue, author: author, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @unsubscribed_mentioned and @outsider also') } subject { notification.new_note(note) } - context 'on service desk issue' do + context 'issue_email_participants' do before do allow(Notify).to receive(:service_desk_new_note_email) - .with(Integer, Integer).and_return(mailer) + .with(Integer, Integer, String).and_return(mailer) allow(::Gitlab::IncomingEmail).to receive(:enabled?) { true } allow(::Gitlab::IncomingEmail).to receive(:supports_wildcard?) { true } @@ -318,7 +336,7 @@ RSpec.describe NotificationService, :mailer do def should_email! expect(Notify).to receive(:service_desk_new_note_email) - .with(issue.id, note.id) + .with(issue.id, note.id, issue.external_author) end def should_not_email! @@ -347,33 +365,19 @@ RSpec.describe NotificationService, :mailer do let(:project) { issue.project } let(:note) { create(:note, noteable: issue, project: project) } - context 'a non-service-desk issue' do + context 'do not exist' do it_should_not_email! end - context 'a service-desk issue' do + context 'do exist' do + let!(:issue_email_participant) { issue.issue_email_participants.create!(email: 'service.desk@example.com') } + before do issue.update!(external_author: 'service.desk@example.com') project.update!(service_desk_enabled: true) end it_should_email! - - context 'where the project has disabled the feature' do - before do - project.update!(service_desk_enabled: false) - end - - it_should_not_email! - end - - context 'when the support bot has unsubscribed' do - before do - issue.unsubscribe(User.support_bot, project) - end - - it_should_not_email! - end end end @@ -881,8 +885,24 @@ RSpec.describe NotificationService, :mailer do end describe '#send_new_release_notifications', :deliver_mails_inline do + let(:release) { create(:release, author: current_user) } + let(:object) { release } + let(:action) { notification.send_new_release_notifications(release) } + + context 'when release author is blocked' do + let(:current_user) { create(:user, :blocked) } + + include_examples 'is not able to send notifications' + end + + context 'when release author is a ghost' do + let(:current_user) { create(:user, :ghost) } + + include_examples 'is not able to send notifications' + end + context 'when recipients for a new release exist' do - let(:release) { create(:release) } + let(:current_user) { create(:user) } it 'calls new_release_email for each relevant recipient' do user_1 = create(:user) @@ -1127,11 +1147,31 @@ RSpec.describe NotificationService, :mailer do should_email(admin) end end + + context 'when the author is not allowed to trigger notifications' do + let(:current_user) { nil } + let(:object) { issue } + let(:action) { notification.new_issue(issue, current_user) } + + context 'because they are blocked' do + let(:current_user) { create(:user, :blocked) } + + include_examples 'is not able to send notifications' + end + + context 'because they are a ghost' do + let(:current_user) { create(:user, :ghost) } + + include_examples 'is not able to send notifications' + end + end end describe '#new_mentions_in_issue' do let(:notification_method) { :new_mentions_in_issue } let(:mentionable) { issue } + let(:object) { mentionable } + let(:action) { send_notifications(@u_mentioned, current_user: current_user) } include_examples 'notifications for new mentions' @@ -1139,6 +1179,18 @@ RSpec.describe NotificationService, :mailer do let(:notification_target) { issue } let(:notification_trigger) { send_notifications(@u_watcher, @u_participant_mentioned, @u_custom_global, @u_mentioned) } end + + context 'where current_user is blocked' do + let(:current_user) { create(:user, :blocked) } + + include_examples 'is not able to send notifications' + end + + context 'where current_user is a ghost' do + let(:current_user) { create(:user, :ghost) } + + include_examples 'is not able to send notifications' + end end describe '#reassigned_issue' do @@ -1751,11 +1803,31 @@ RSpec.describe NotificationService, :mailer do it { should_not_email(participant) } end end + + context 'when the author is not allowed to trigger notifications' do + let(:current_user) { nil } + let(:object) { merge_request } + let(:action) { notification.new_merge_request(merge_request, current_user) } + + context 'because they are blocked' do + let(:current_user) { create(:user, :blocked) } + + it_behaves_like 'is not able to send notifications' + end + + context 'because they are a ghost' do + let(:current_user) { create(:user, :ghost) } + + it_behaves_like 'is not able to send notifications' + end + end end describe '#new_mentions_in_merge_request' do let(:notification_method) { :new_mentions_in_merge_request } let(:mentionable) { merge_request } + let(:object) { mentionable } + let(:action) { send_notifications(@u_mentioned, current_user: current_user) } include_examples 'notifications for new mentions' @@ -1763,6 +1835,18 @@ RSpec.describe NotificationService, :mailer do let(:notification_target) { merge_request } let(:notification_trigger) { send_notifications(@u_watcher, @u_participant_mentioned, @u_custom_global, @u_mentioned) } end + + context 'where current_user is blocked' do + let(:current_user) { create(:user, :blocked) } + + include_examples 'is not able to send notifications' + end + + context 'where current_user is a ghost' do + let(:current_user) { create(:user, :ghost) } + + include_examples 'is not able to send notifications' + end end describe '#reassigned_merge_request' do @@ -1867,6 +1951,42 @@ RSpec.describe NotificationService, :mailer do end end + describe '#change_in_merge_request_draft_status' do + let(:merge_request) { create(:merge_request, author: author, source_project: project) } + + let_it_be(:current_user) { create(:user) } + + it 'sends emails to relevant users only', :aggregate_failures do + notification.change_in_merge_request_draft_status(merge_request, current_user) + + merge_request.reviewers.each { |reviewer| should_email(reviewer) } + merge_request.assignees.each { |assignee| should_email(assignee) } + should_email(merge_request.author) + should_email(@u_watcher) + should_email(@subscriber) + should_email(@watcher_and_subscriber) + should_email(@u_guest_watcher) + should_not_email(@u_participant_mentioned) + should_not_email(@u_guest_custom) + should_not_email(@u_custom_global) + should_not_email(@unsubscriber) + should_not_email(@u_participating) + should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) + end + + it_behaves_like 'participating notifications' do + let(:participant) { create(:user, username: 'user-participant') } + let(:issuable) { merge_request } + let(:notification_trigger) { notification.change_in_merge_request_draft_status(merge_request, @u_disabled) } + end + + it_behaves_like 'project emails are disabled' do + let(:notification_target) { merge_request } + let(:notification_trigger) { notification.change_in_merge_request_draft_status(merge_request, @u_disabled) } + end + end + describe '#push_to_merge_request' do before do update_custom_notification(:push_to_merge_request, @u_guest_custom, resource: project) @@ -2159,8 +2279,38 @@ RSpec.describe NotificationService, :mailer do end describe '#merge_when_pipeline_succeeds' do + before do + update_custom_notification(:merge_when_pipeline_succeeds, @u_guest_custom, resource: project) + update_custom_notification(:merge_when_pipeline_succeeds, @u_custom_global) + end + it 'send notification that merge will happen when pipeline succeeds' do notification.merge_when_pipeline_succeeds(merge_request, assignee) + + should_email(merge_request.author) + should_email(@u_watcher) + should_email(@subscriber) + should_email(@u_guest_custom) + should_email(@u_custom_global) + should_not_email(@unsubscriber) + should_not_email(@u_disabled) + end + + it 'does not send notification if the custom event is disabled' do + update_custom_notification(:merge_when_pipeline_succeeds, @u_guest_custom, resource: project, value: false) + update_custom_notification(:merge_when_pipeline_succeeds, @u_custom_global, resource: nil, value: false) + notification.merge_when_pipeline_succeeds(merge_request, assignee) + + should_not_email(@u_guest_custom) + should_not_email(@u_custom_global) + end + + it 'sends notification to participants even if the custom event is disabled' do + update_custom_notification(:merge_when_pipeline_succeeds, merge_request.author, resource: project, value: false) + update_custom_notification(:merge_when_pipeline_succeeds, @u_watcher, resource: project, value: false) + update_custom_notification(:merge_when_pipeline_succeeds, @subscriber, resource: project, value: false) + notification.merge_when_pipeline_succeeds(merge_request, assignee) + should_email(merge_request.author) should_email(@u_watcher) should_email(@subscriber) @@ -2694,7 +2844,7 @@ RSpec.describe NotificationService, :mailer do end it 'filters out guests when new merge request is created' do - notification.new_merge_request(merge_request1, @u_disabled) + notification.new_merge_request(merge_request1, developer) should_not_email(guest) should_email(assignee) diff --git a/spec/services/onboarding_progress_service_spec.rb b/spec/services/onboarding_progress_service_spec.rb index 340face4ae8..ef4f4f0d822 100644 --- a/spec/services/onboarding_progress_service_spec.rb +++ b/spec/services/onboarding_progress_service_spec.rb @@ -3,9 +3,49 @@ require 'spec_helper' RSpec.describe OnboardingProgressService do + describe '.async' do + let_it_be(:namespace) { create(:namespace) } + let_it_be(:action) { :git_pull } + + subject(:execute_service) { described_class.async(namespace.id).execute(action: action) } + + context 'when not onboarded' do + it 'does not schedule a worker' do + expect(Namespaces::OnboardingProgressWorker).not_to receive(:perform_async) + + execute_service + end + end + + context 'when onboarded' do + before do + OnboardingProgress.onboard(namespace) + end + + context 'when action is already completed' do + before do + OnboardingProgress.register(namespace, action) + end + + it 'does not schedule a worker' do + expect(Namespaces::OnboardingProgressWorker).not_to receive(:perform_async) + + execute_service + end + end + + context 'when action is not yet completed' do + it 'schedules a worker' do + expect(Namespaces::OnboardingProgressWorker).to receive(:perform_async) + + execute_service + end + end + end + end + describe '#execute' do - let(:namespace) { create(:namespace, parent: root_namespace) } - let(:root_namespace) { nil } + let(:namespace) { create(:namespace) } let(:action) { :namespace_action } subject(:execute_service) { described_class.new(namespace).execute(action: :subscription_created) } @@ -23,16 +63,16 @@ RSpec.describe OnboardingProgressService do end context 'when the namespace is not the root' do - let(:root_namespace) { build(:namespace) } + let(:group) { create(:group, :nested) } before do - OnboardingProgress.onboard(root_namespace) + OnboardingProgress.onboard(group) end - it 'registers a namespace onboarding progress action for the root namespace' do + it 'does not register a namespace onboarding progress action' do execute_service - expect(OnboardingProgress.completed?(root_namespace, :subscription_created)).to eq(true) + expect(OnboardingProgress.completed?(group, :subscription_created)).to be(nil) end end @@ -42,7 +82,7 @@ RSpec.describe OnboardingProgressService do it 'does not register a namespace onboarding progress action' do execute_service - expect(OnboardingProgress.completed?(root_namespace, :subscription_created)).to be(nil) + expect(OnboardingProgress.completed?(namespace, :subscription_created)).to be(nil) end end end diff --git a/spec/services/packages/composer/create_package_service_spec.rb b/spec/services/packages/composer/create_package_service_spec.rb index 4f1a46e7e45..526c7b4929b 100644 --- a/spec/services/packages/composer/create_package_service_spec.rb +++ b/spec/services/packages/composer/create_package_service_spec.rb @@ -28,6 +28,8 @@ RSpec.describe Packages::Composer::CreatePackageService do let(:branch) { project.repository.find_branch('master') } it 'creates the package' do + expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, nil) + expect { subject } .to change { Packages::Package.composer.count }.by(1) .and change { Packages::Composer::Metadatum.count }.by(1) @@ -54,6 +56,8 @@ RSpec.describe Packages::Composer::CreatePackageService do end it 'creates the package' do + expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, nil) + expect { subject } .to change { Packages::Package.composer.count }.by(1) .and change { Packages::Composer::Metadatum.count }.by(1) @@ -80,6 +84,8 @@ RSpec.describe Packages::Composer::CreatePackageService do end it 'does not create a new package' do + expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, nil) + expect { subject } .to change { Packages::Package.composer.count }.by(0) .and change { Packages::Composer::Metadatum.count }.by(0) @@ -101,6 +107,8 @@ RSpec.describe Packages::Composer::CreatePackageService do let!(:other_package) { create(:package, name: package_name, version: 'dev-master', project: other_project) } it 'creates the package' do + expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, nil) + expect { subject } .to change { Packages::Package.composer.count }.by(1) .and change { Packages::Composer::Metadatum.count }.by(1) diff --git a/spec/services/packages/create_event_service_spec.rb b/spec/services/packages/create_event_service_spec.rb index f7bab0e5a9f..122f1e88ad0 100644 --- a/spec/services/packages/create_event_service_spec.rb +++ b/spec/services/packages/create_event_service_spec.rb @@ -57,18 +57,6 @@ RSpec.describe Packages::CreateEventService do end shared_examples 'redis package unique event creation' do |originator_type, expected_scope| - context 'with feature flag disable' do - before do - stub_feature_flags(collect_package_events_redis: false) - end - - it 'does not track the event' do - expect(::Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event) - - subject - end - end - it 'tracks the event' do expect(::Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(/package/, values: user.id) @@ -77,18 +65,6 @@ RSpec.describe Packages::CreateEventService do end shared_examples 'redis package count event creation' do |originator_type, expected_scope| - context 'with feature flag disabled' do - before do - stub_feature_flags(collect_package_events_redis: false) - end - - it 'does not track the event' do - expect(::Gitlab::UsageDataCounters::PackageEventCounter).not_to receive(:count) - - subject - end - end - it 'tracks the event' do expect(::Gitlab::UsageDataCounters::PackageEventCounter).to receive(:count).at_least(:once) diff --git a/spec/services/packages/create_temporary_package_service_spec.rb b/spec/services/packages/create_temporary_package_service_spec.rb new file mode 100644 index 00000000000..4b8d37401d8 --- /dev/null +++ b/spec/services/packages/create_temporary_package_service_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::CreateTemporaryPackageService do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:params) { {} } + let_it_be(:package_name) { 'my-package' } + let_it_be(:package_type) { 'rubygems' } + + describe '#execute' do + subject { described_class.new(project, user, params).execute(package_type, name: package_name) } + + let(:package) { Packages::Package.last } + + it 'creates the package', :aggregate_failures do + expect { subject }.to change { Packages::Package.count }.by(1) + + expect(package).to be_valid + expect(package).to be_processing + expect(package.name).to eq(package_name) + expect(package.version).to start_with(described_class::PACKAGE_VERSION) + expect(package.package_type).to eq(package_type) + end + + it 'can create two packages in a row', :aggregate_failures do + expect { subject }.to change { Packages::Package.count }.by(1) + + expect do + described_class.new(project, user, params).execute(package_type, name: package_name) + end.to change { Packages::Package.count }.by(1) + + expect(package).to be_valid + expect(package).to be_processing + expect(package.name).to eq(package_name) + expect(package.version).to start_with(described_class::PACKAGE_VERSION) + expect(package.package_type).to eq(package_type) + end + + it_behaves_like 'assigns the package creator' + it_behaves_like 'assigns build to package' + end +end diff --git a/spec/services/packages/debian/get_or_create_incoming_service_spec.rb b/spec/services/packages/debian/find_or_create_incoming_service_spec.rb index ab99b091246..e1393c774b1 100644 --- a/spec/services/packages/debian/get_or_create_incoming_service_spec.rb +++ b/spec/services/packages/debian/find_or_create_incoming_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Packages::Debian::GetOrCreateIncomingService do +RSpec.describe Packages::Debian::FindOrCreateIncomingService do let_it_be(:project) { create(:project) } let_it_be(:user) { create(:user) } diff --git a/spec/services/packages/debian/find_or_create_package_service_spec.rb b/spec/services/packages/debian/find_or_create_package_service_spec.rb new file mode 100644 index 00000000000..3582b1f1dc3 --- /dev/null +++ b/spec/services/packages/debian/find_or_create_package_service_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Debian::FindOrCreatePackageService do + let_it_be(:distribution) { create(:debian_project_distribution) } + let_it_be(:project) { distribution.project } + let_it_be(:user) { create(:user) } + let(:params) { { name: 'foo', version: '1.0+debian', distribution_name: distribution.codename } } + + subject(:service) { described_class.new(project, user, params) } + + describe '#execute' do + subject { service.execute } + + let(:package) { subject.payload[:package] } + + context 'run once' do + it 'creates a new package', :aggregate_failures do + expect { subject }.to change { ::Packages::Package.count }.by(1) + expect(subject).to be_success + + expect(package).to be_valid + expect(package.project_id).to eq(project.id) + expect(package.creator_id).to eq(user.id) + expect(package.name).to eq('foo') + expect(package.version).to eq('1.0+debian') + expect(package).to be_debian + expect(package.debian_publication.distribution).to eq(distribution) + end + end + + context 'run twice' do + let(:subject2) { service.execute } + + let(:package2) { service.execute.payload[:package] } + + it 'returns the same object' do + expect { subject }.to change { ::Packages::Package.count }.by(1) + expect { package2 }.not_to change { ::Packages::Package.count } + + expect(package2.id).to eq(package.id) + end + end + + context 'with non-existing distribution' do + let(:params) { { name: 'foo', version: '1.0+debian', distribution_name: 'not-existing' } } + + it 'raises ActiveRecord::RecordNotFound' do + expect { package }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/services/packages/maven/metadata/append_package_file_service_spec.rb b/spec/services/packages/maven/metadata/append_package_file_service_spec.rb new file mode 100644 index 00000000000..c406ab93630 --- /dev/null +++ b/spec/services/packages/maven/metadata/append_package_file_service_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Packages::Maven::Metadata::AppendPackageFileService do + let_it_be(:package) { create(:maven_package, version: nil) } + + let(:service) { described_class.new(package: package, metadata_content: content) } + let(:content) { 'test' } + + describe '#execute' do + subject { service.execute } + + context 'with some content' do + it 'creates all the related package files', :aggregate_failures do + expect { subject }.to change { package.package_files.count }.by(5) + expect(subject).to be_success + + expect_file(metadata_file_name, with_content: content, with_content_type: 'application/xml') + expect_file("#{metadata_file_name}.md5") + expect_file("#{metadata_file_name}.sha1") + expect_file("#{metadata_file_name}.sha256") + expect_file("#{metadata_file_name}.sha512") + end + end + + context 'with nil content' do + let(:content) { nil } + + it_behaves_like 'returning an error service response', message: 'metadata content is not set' + end + + context 'with nil package' do + let(:package) { nil } + + it_behaves_like 'returning an error service response', message: 'package is not set' + end + + def expect_file(file_name, with_content: nil, with_content_type: '') + package_file = package.package_files.recent.with_file_name(file_name).first + + expect(package_file.file).to be_present + expect(package_file.file_name).to eq(file_name) + expect(package_file.size).to be > 0 + expect(package_file.file_md5).to be_present + expect(package_file.file_sha1).to be_present + expect(package_file.file_sha256).to be_present + expect(package_file.file.content_type).to eq(with_content_type) + + if with_content + expect(package_file.file.read).to eq(with_content) + end + end + + def metadata_file_name + ::Packages::Maven::Metadata.filename + end + end +end diff --git a/spec/services/packages/maven/metadata/create_plugins_xml_service_spec.rb b/spec/services/packages/maven/metadata/create_plugins_xml_service_spec.rb new file mode 100644 index 00000000000..6fc1087940d --- /dev/null +++ b/spec/services/packages/maven/metadata/create_plugins_xml_service_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Packages::Maven::Metadata::CreatePluginsXmlService do + let_it_be(:group_id) { 'my/test' } + let_it_be(:package) { create(:maven_package, name: group_id, version: nil) } + + let(:plugins_in_database) { %w[one-maven-plugin two three-maven-plugin] } + let(:plugins_in_xml) { %w[one-maven-plugin two three-maven-plugin] } + let(:service) { described_class.new(metadata_content: metadata_xml, package: package) } + + describe '#execute' do + subject { service.execute } + + before do + next unless package + + plugins_in_database.each do |plugin| + create( + :maven_package, + name: "#{group_id}/#{plugin}", + version: '1.0.0', + project: package.project, + maven_metadatum_attributes: { + app_group: group_id.tr('/', '.'), + app_name: plugin, + app_version: '1.0.0' + } + ) + end + end + + shared_examples 'returning an xml with plugins from the database' do + it 'returns an metadata versions xml with versions in the database', :aggregate_failures do + expect(subject).to be_success + expect(subject.payload[:changes_exist]).to eq(true) + expect(subject.payload[:empty_versions]).to eq(false) + expect(plugins_from(subject.payload[:metadata_content])).to match_array(plugins_in_database) + end + end + + shared_examples 'returning no changes' do + it 'returns no changes', :aggregate_failures do + expect(subject).to be_success + expect(subject.payload).to eq(changes_exist: false, empty_versions: false) + end + end + + context 'with same plugins on both sides' do + it_behaves_like 'returning no changes' + end + + context 'with more plugins' do + let(:additional_plugins) { %w[four-maven-plugin five] } + + context 'in database' do + let(:plugins_in_database) { plugins_in_xml + additional_plugins } + + # we can't distinguish that the additional plugin are actually maven plugins + it_behaves_like 'returning no changes' + end + + context 'in xml' do + let(:plugins_in_xml) { plugins_in_database + additional_plugins } + + it_behaves_like 'returning an xml with plugins from the database' + end + end + + context 'with no versions in the database' do + let(:plugins_in_database) { [] } + + it 'returns a success', :aggregate_failures do + result = subject + + expect(result).to be_success + expect(result.payload).to eq(changes_exist: true, empty_plugins: true) + end + end + + context 'with an incomplete metadata content' do + let(:metadata_xml) { '<metadata></metadata>' } + + it_behaves_like 'returning an error service response', message: 'metadata_content is invalid' + end + + context 'with an invalid metadata content' do + let(:metadata_xml) { '<meta></metadata>' } + + it_behaves_like 'returning an error service response', message: 'metadata_content is invalid' + end + + it_behaves_like 'handling metadata content pointing to a file for the create xml service' + + it_behaves_like 'handling invalid parameters for create xml service' + end + + def metadata_xml + Nokogiri::XML::Builder.new do |xml| + xml.metadata do + xml.plugins do + plugins_in_xml.each do |plugin| + xml.plugin do + xml.name(plugin) + xml.prefix(prefix_from(plugin)) + xml.artifactId(plugin) + end + end + end + end + end.to_xml + end + + def prefix_from(artifact_id) + artifact_id.gsub(/-?maven-?/, '') + .gsub(/-?plugin-?/, '') + end + + def plugins_from(xml_content) + doc = Nokogiri::XML(xml_content) + doc.xpath('//metadata/plugins/plugin/artifactId').map(&:content) + end +end diff --git a/spec/services/packages/maven/metadata/create_versions_xml_service_spec.rb b/spec/services/packages/maven/metadata/create_versions_xml_service_spec.rb new file mode 100644 index 00000000000..39c6feb5d12 --- /dev/null +++ b/spec/services/packages/maven/metadata/create_versions_xml_service_spec.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Packages::Maven::Metadata::CreateVersionsXmlService do + let_it_be(:package) { create(:maven_package, version: nil) } + + let(:versions_in_database) { %w[1.3 2.0-SNAPSHOT 1.6 1.4 1.5-SNAPSHOT] } + let(:versions_in_xml) { %w[1.3 2.0-SNAPSHOT 1.6 1.4 1.5-SNAPSHOT] } + let(:version_latest) { nil } + let(:version_release) { '1.4' } + let(:service) { described_class.new(metadata_content: metadata_xml, package: package) } + + describe '#execute' do + subject { service.execute } + + before do + next unless package + + versions_in_database.each do |version| + create(:maven_package, name: package.name, version: version, project: package.project) + end + end + + shared_examples 'returning an xml with versions in the database' do + it 'returns an metadata versions xml with versions in the database', :aggregate_failures do + result = subject + + expect(result).to be_success + expect(versions_from(result.payload[:metadata_content])).to match_array(versions_in_database) + end + end + + shared_examples 'returning an xml with' do |release:, latest:| + it 'returns an xml with the updated release and latest versions', :aggregate_failures do + result = subject + + expect(result).to be_success + expect(result.payload[:changes_exist]).to be_truthy + xml = result.payload[:metadata_content] + expect(release_from(xml)).to eq(release) + expect(latest_from(xml)).to eq(latest) + end + end + + context 'with same versions in both sides' do + it 'returns no changes', :aggregate_failures do + result = subject + + expect(result).to be_success + expect(result.payload).to eq(changes_exist: false, empty_versions: false) + end + end + + context 'with more versions' do + let(:additional_versions) { %w[5.5 5.6 5.7-SNAPSHOT] } + + context 'in the xml side' do + let(:versions_in_xml) { versions_in_database + additional_versions } + + it_behaves_like 'returning an xml with versions in the database' + end + + context 'in the database side' do + let(:versions_in_database) { versions_in_xml + additional_versions } + + it_behaves_like 'returning an xml with versions in the database' + end + end + + context 'with completely different versions' do + let(:versions_in_database) { %w[1.0 1.1 1.2] } + let(:versions_in_xml) { %w[2.0 2.1 2.2] } + + it_behaves_like 'returning an xml with versions in the database' + end + + context 'with no versions in the database' do + let(:versions_in_database) { [] } + + it 'returns a success', :aggregate_failures do + result = subject + + expect(result).to be_success + expect(result.payload).to eq(changes_exist: true, empty_versions: true) + end + + context 'with an xml without a release version' do + let(:version_release) { nil } + + it 'returns a success', :aggregate_failures do + result = subject + + expect(result).to be_success + expect(result.payload).to eq(changes_exist: true, empty_versions: true) + end + end + end + + context 'with differences in both sides' do + let(:shared_versions) { %w[1.3 2.0-SNAPSHOT 1.6 1.4 1.5-SNAPSHOT] } + let(:additional_versions_in_xml) { %w[5.5 5.6 5.7-SNAPSHOT] } + let(:versions_in_xml) { shared_versions + additional_versions_in_xml } + let(:additional_versions_in_database) { %w[6.5 6.6 6.7-SNAPSHOT] } + let(:versions_in_database) { shared_versions + additional_versions_in_database } + + it_behaves_like 'returning an xml with versions in the database' + end + + context 'with a new release and latest from the database' do + let(:versions_in_database) { versions_in_xml + %w[4.1 4.2-SNAPSHOT] } + + it_behaves_like 'returning an xml with', release: '4.1', latest: nil + + context 'with a latest in the xml' do + let(:version_latest) { '1.6' } + + it_behaves_like 'returning an xml with', release: '4.1', latest: '4.2-SNAPSHOT' + end + end + + context 'with release and latest not existing in the database' do + let(:version_release) { '7.0' } + let(:version_latest) { '8.0-SNAPSHOT' } + + it_behaves_like 'returning an xml with', release: '1.4', latest: '1.5-SNAPSHOT' + end + + context 'with added versions in the database side no more recent than release' do + let(:versions_in_database) { versions_in_xml + %w[4.1 4.2-SNAPSHOT] } + + before do + ::Packages::Package.find_by(name: package.name, version: '4.1').update!(created_at: 2.weeks.ago) + ::Packages::Package.find_by(name: package.name, version: '4.2-SNAPSHOT').update!(created_at: 2.weeks.ago) + end + + it_behaves_like 'returning an xml with', release: '1.4', latest: nil + + context 'with a latest in the xml' do + let(:version_latest) { '1.6' } + + it_behaves_like 'returning an xml with', release: '1.4', latest: '1.5-SNAPSHOT' + end + end + + context 'only snapshot versions are in the database' do + let(:versions_in_database) { %w[4.2-SNAPSHOT] } + + it_behaves_like 'returning an xml with', release: nil, latest: nil + + it 'returns an xml without any release element' do + result = subject + + xml_doc = Nokogiri::XML(result.payload[:metadata_content]) + expect(xml_doc.xpath('//metadata/versioning/release')).to be_empty + end + end + + context 'last updated timestamp' do + let(:versions_in_database) { versions_in_xml + %w[4.1 4.2-SNAPSHOT] } + + it 'updates the last updated timestamp' do + original = last_updated_from(metadata_xml) + + result = subject + + expect(result).to be_success + expect(original).not_to eq(last_updated_from(result.payload[:metadata_content])) + end + end + + context 'with an incomplete metadata content' do + let(:metadata_xml) { '<metadata></metadata>' } + + it_behaves_like 'returning an error service response', message: 'metadata_content is invalid' + end + + context 'with an invalid metadata content' do + let(:metadata_xml) { '<meta></metadata>' } + + it_behaves_like 'returning an error service response', message: 'metadata_content is invalid' + end + + it_behaves_like 'handling metadata content pointing to a file for the create xml service' + + it_behaves_like 'handling invalid parameters for create xml service' + end + + def metadata_xml + Nokogiri::XML::Builder.new do |xml| + xml.metadata do + xml.groupId(package.maven_metadatum.app_group) + xml.artifactId(package.maven_metadatum.app_name) + xml.versioning do + xml.release(version_release) if version_release + xml.latest(version_latest) if version_latest + xml.lastUpdated('20210113130531') + xml.versions do + versions_in_xml.each do |version| + xml.version(version) + end + end + end + end + end.to_xml + end + + def versions_from(xml_content) + doc = Nokogiri::XML(xml_content) + doc.xpath('//metadata/versioning/versions/version').map(&:content) + end + + def release_from(xml_content) + doc = Nokogiri::XML(xml_content) + doc.xpath('//metadata/versioning/release').first&.content + end + + def latest_from(xml_content) + doc = Nokogiri::XML(xml_content) + doc.xpath('//metadata/versioning/latest').first&.content + end + + def last_updated_from(xml_content) + doc = Nokogiri::XML(xml_content) + doc.xpath('//metadata/versioning/lastUpdated').first.content + end +end diff --git a/spec/services/packages/maven/metadata/sync_service_spec.rb b/spec/services/packages/maven/metadata/sync_service_spec.rb new file mode 100644 index 00000000000..f5634159e6d --- /dev/null +++ b/spec/services/packages/maven/metadata/sync_service_spec.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Packages::Maven::Metadata::SyncService do + using RSpec::Parameterized::TableSyntax + + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be_with_reload(:versionless_package_for_versions) { create(:maven_package, name: 'test', version: nil, project: project) } + let_it_be_with_reload(:metadata_file_for_versions) { create(:package_file, :xml, package: versionless_package_for_versions) } + + let(:service) { described_class.new(container: project, current_user: user, params: { package_name: versionless_package_for_versions.name }) } + + describe '#execute' do + let(:create_versions_xml_service_double) { double(::Packages::Maven::Metadata::CreateVersionsXmlService, execute: create_versions_xml_service_response) } + let(:append_package_file_service_double) { double(::Packages::Maven::Metadata::AppendPackageFileService, execute: append_package_file_service_response) } + + let(:create_versions_xml_service_response) { ServiceResponse.success(payload: { changes_exist: true, empty_versions: false, metadata_content: 'test' }) } + let(:append_package_file_service_response) { ServiceResponse.success(message: 'New metadata package files created') } + + subject { service.execute } + + before do + allow(::Packages::Maven::Metadata::CreateVersionsXmlService) + .to receive(:new).with(metadata_content: an_instance_of(ObjectStorage::Concern::OpenFile), package: versionless_package_for_versions).and_return(create_versions_xml_service_double) + allow(::Packages::Maven::Metadata::AppendPackageFileService) + .to receive(:new).with(metadata_content: an_instance_of(String), package: versionless_package_for_versions).and_return(append_package_file_service_double) + end + + context 'permissions' do + where(:role, :expected_result) do + :anonymous | :rejected + :developer | :rejected + :maintainer | :accepted + end + + with_them do + if params[:role] == :anonymous + let_it_be(:user) { nil } + end + + before do + project.send("add_#{role}", user) unless role == :anonymous + end + + if params[:expected_result] == :rejected + it_behaves_like 'returning an error service response', message: 'Not allowed' + else + it_behaves_like 'returning a success service response', message: 'New metadata package files created' + end + end + end + + context 'with a maintainer' do + before do + project.add_maintainer(user) + end + + context 'with a jar package' do + before do + expect(::Packages::Maven::Metadata::CreatePluginsXmlService).not_to receive(:new) + end + + context 'with no changes' do + let(:create_versions_xml_service_response) { ServiceResponse.success(payload: { changes_exist: false }) } + + before do + expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new) + end + + it_behaves_like 'returning a success service response', message: 'No changes for versions xml' + end + + context 'with changes' do + let(:create_versions_xml_service_response) { ServiceResponse.success(payload: { changes_exist: true, empty_versions: false, metadata_content: 'new metadata' }) } + + it_behaves_like 'returning a success service response', message: 'New metadata package files created' + + context 'with empty versions' do + let(:create_versions_xml_service_response) { ServiceResponse.success(payload: { changes_exist: true, empty_versions: true }) } + + before do + expect(service.send(:versionless_package_for_versions)).to receive(:destroy!) + expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new) + end + + it_behaves_like 'returning a success service response', message: 'Versionless package for versions destroyed' + end + end + + context 'with a too big maven metadata file for versions' do + before do + metadata_file_for_versions.update!(size: 100.megabytes) + end + + it_behaves_like 'returning an error service response', message: 'Metadata file for versions is too big' + end + + context 'an error from the create versions xml service' do + let(:create_versions_xml_service_response) { ServiceResponse.error(message: 'metadata_content is invalid') } + + before do + expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new) + end + + it_behaves_like 'returning an error service response', message: 'metadata_content is invalid' + end + + context 'an error from the append package file service' do + let(:append_package_file_service_response) { ServiceResponse.error(message: 'metadata content is not set') } + + it_behaves_like 'returning an error service response', message: 'metadata content is not set' + end + + context 'without a package name' do + let(:service) { described_class.new(container: project, current_user: user, params: { package_name: nil }) } + + before do + expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new) + expect(::Packages::Maven::Metadata::CreateVersionsXmlService).not_to receive(:new) + end + + it_behaves_like 'returning an error service response', message: 'Blank package name' + end + + context 'without a versionless package for version' do + before do + versionless_package_for_versions.update!(version: '2.2.2') + expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new) + expect(::Packages::Maven::Metadata::CreateVersionsXmlService).not_to receive(:new) + end + + it_behaves_like 'returning an error service response', message: 'Non existing versionless package' + end + + context 'without a metadata package file for versions' do + before do + versionless_package_for_versions.package_files.update_all(file_name: 'test.txt') + expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new) + expect(::Packages::Maven::Metadata::CreateVersionsXmlService).not_to receive(:new) + end + + it_behaves_like 'returning an error service response', message: 'Non existing metadata file for versions' + end + + context 'without a project' do + let(:service) { described_class.new(container: nil, current_user: user, params: { package_name: versionless_package_for_versions.name }) } + + before do + expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new) + expect(::Packages::Maven::Metadata::CreateVersionsXmlService).not_to receive(:new) + end + + it_behaves_like 'returning an error service response', message: 'Not allowed' + end + end + + context 'with a maven plugin package' do + let_it_be(:versionless_package_name_for_plugins) { versionless_package_for_versions.maven_metadatum.app_group.tr('.', '/') } + let_it_be_with_reload(:versionless_package_for_plugins) { create(:maven_package, name: versionless_package_name_for_plugins, version: nil, project: project) } + let_it_be_with_reload(:metadata_file_for_plugins) { create(:package_file, :xml, package: versionless_package_for_plugins) } + + let(:create_plugins_xml_service_double) { double(::Packages::Maven::Metadata::CreatePluginsXmlService, execute: create_plugins_xml_service_response) } + let(:create_plugins_xml_service_response) { ServiceResponse.success(payload: { changes_exist: false }) } + + before do + allow(::Packages::Maven::Metadata::CreatePluginsXmlService) + .to receive(:new).with(metadata_content: an_instance_of(ObjectStorage::Concern::OpenFile), package: versionless_package_for_plugins).and_return(create_plugins_xml_service_double) + allow(::Packages::Maven::Metadata::AppendPackageFileService) + .to receive(:new).with(metadata_content: an_instance_of(String), package: versionless_package_for_plugins).and_return(append_package_file_service_double) + end + + context 'with no changes' do + let(:create_versions_xml_service_response) { ServiceResponse.success(payload: { changes_exist: false }) } + + before do + expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new) + end + + it_behaves_like 'returning a success service response', message: 'No changes for versions xml' + end + + context 'with changes in the versions xml' do + let(:create_versions_xml_service_response) { ServiceResponse.success(payload: { changes_exist: true, empty_versions: false, metadata_content: 'new metadata' }) } + + it_behaves_like 'returning a success service response', message: 'New metadata package files created' + + context 'with changes in the plugin xml' do + let(:create_plugins_xml_service_response) { ServiceResponse.success(payload: { changes_exist: true, empty_plugins: false, metadata_content: 'new metadata' }) } + + it_behaves_like 'returning a success service response', message: 'New metadata package files created' + end + + context 'with empty versions' do + let(:create_versions_xml_service_response) { ServiceResponse.success(payload: { changes_exist: true, empty_versions: true }) } + let(:create_plugins_xml_service_response) { ServiceResponse.success(payload: { changes_exist: true, empty_plugins: true }) } + + before do + expect(service.send(:versionless_package_for_versions)).to receive(:destroy!) + expect(service.send(:metadata_package_file_for_plugins).package).to receive(:destroy!) + expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new) + end + + it_behaves_like 'returning a success service response', message: 'Versionless package for versions destroyed' + end + + context 'with a too big maven metadata file for versions' do + before do + metadata_file_for_plugins.update!(size: 100.megabytes) + end + + it_behaves_like 'returning an error service response', message: 'Metadata file for plugins is too big' + end + + context 'an error from the create versions xml service' do + let(:create_plugins_xml_service_response) { ServiceResponse.error(message: 'metadata_content is invalid') } + + before do + expect(::Packages::Maven::Metadata::CreateVersionsXmlService).not_to receive(:new) + expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new) + end + + it_behaves_like 'returning an error service response', message: 'metadata_content is invalid' + end + + context 'an error from the append package file service' do + let(:create_plugins_xml_service_response) { ServiceResponse.success(payload: { changes_exist: true, empty_plugins: false, metadata_content: 'new metadata' }) } + let(:append_package_file_service_response) { ServiceResponse.error(message: 'metadata content is not set') } + + before do + expect(::Packages::Maven::Metadata::CreateVersionsXmlService).not_to receive(:new) + end + + it_behaves_like 'returning an error service response', message: 'metadata content is not set' + end + + context 'without a versionless package for plugins' do + before do + versionless_package_for_plugins.package_files.update_all(file_name: 'test.txt') + expect(::Packages::Maven::Metadata::CreatePluginsXmlService).not_to receive(:new) + end + + it_behaves_like 'returning a success service response', message: 'New metadata package files created' + end + + context 'without a metadata package file for plugins' do + before do + versionless_package_for_plugins.package_files.update_all(file_name: 'test.txt') + expect(::Packages::Maven::Metadata::CreatePluginsXmlService).not_to receive(:new) + end + + it_behaves_like 'returning a success service response', message: 'New metadata package files created' + end + end + end + end + end +end diff --git a/spec/services/packages/npm/create_package_service_spec.rb b/spec/services/packages/npm/create_package_service_spec.rb index 10fce6c1651..ba5729eaf59 100644 --- a/spec/services/packages/npm/create_package_service_spec.rb +++ b/spec/services/packages/npm/create_package_service_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Packages::Npm::CreatePackageService do end let(:override) { {} } - let(:package_name) { "@#{namespace.path}/my-app".freeze } + let(:package_name) { "@#{namespace.path}/my-app" } subject { described_class.new(project, user, params).execute } @@ -42,29 +42,35 @@ RSpec.describe Packages::Npm::CreatePackageService do it { expect(subject.name).to eq(package_name) } it { expect(subject.version).to eq(version) } + + context 'with build info' do + let(:job) { create(:ci_build, user: user) } + let(:params) { super().merge(build: job) } + + it_behaves_like 'assigns build to package' + it_behaves_like 'assigns status to package' + + it 'creates a package file build info' do + expect { subject }.to change { Packages::PackageFileBuildInfo.count }.by(1) + end + end end describe '#execute' do context 'scoped package' do it_behaves_like 'valid package' + end - context 'with build info' do - let(:job) { create(:ci_build, user: user) } - let(:params) { super().merge(build: job) } - - it_behaves_like 'assigns build to package' - it_behaves_like 'assigns status to package' + context 'scoped package not following the naming convention' do + let(:package_name) { '@any-scope/package' } - it 'creates a package file build info' do - expect { subject }.to change { Packages::PackageFileBuildInfo.count }.by(1) - end - end + it_behaves_like 'valid package' end - context 'invalid package name' do - let(:package_name) { "@#{namespace.path}/my-group/my-app".freeze } + context 'unscoped package' do + let(:package_name) { 'unscoped-package' } - it { expect { subject }.to raise_error(ActiveRecord::RecordInvalid) } + it_behaves_like 'valid package' end context 'package already exists' do @@ -84,11 +90,18 @@ RSpec.describe Packages::Npm::CreatePackageService do it { expect(subject[:message]).to be 'File is too large.' } end - context 'with incorrect namespace' do - let(:package_name) { '@my_other_namespace/my-app' } - - it 'raises a RecordInvalid error' do - expect { subject }.to raise_error(ActiveRecord::RecordInvalid) + [ + '@inv@lid_scope/package', + '@scope/sub/group', + '@scope/../../package', + '@scope%2e%2e%2fpackage' + ].each do |invalid_package_name| + context "with invalid name #{invalid_package_name}" do + let(:package_name) { invalid_package_name } + + it 'raises a RecordInvalid error' do + expect { subject }.to raise_error(ActiveRecord::RecordInvalid) + end end end diff --git a/spec/services/packages/nuget/create_package_service_spec.rb b/spec/services/packages/nuget/create_package_service_spec.rb deleted file mode 100644 index e338ac36fc3..00000000000 --- a/spec/services/packages/nuget/create_package_service_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true -require 'spec_helper' - -RSpec.describe Packages::Nuget::CreatePackageService do - let_it_be(:project) { create(:project) } - let_it_be(:user) { create(:user) } - let_it_be(:params) { {} } - - describe '#execute' do - subject { described_class.new(project, user, params).execute } - - let(:package) { Packages::Package.last } - - it 'creates the package' do - expect { subject }.to change { Packages::Package.count }.by(1) - - expect(package).to be_valid - expect(package.name).to eq(Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) - expect(package.version).to start_with(Packages::Nuget::CreatePackageService::PACKAGE_VERSION) - expect(package.package_type).to eq('nuget') - end - - it 'can create two packages in a row' do - expect { subject }.to change { Packages::Package.count }.by(1) - expect { described_class.new(project, user, params).execute }.to change { Packages::Package.count }.by(1) - - expect(package).to be_valid - expect(package.name).to eq(Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) - expect(package.version).to start_with(Packages::Nuget::CreatePackageService::PACKAGE_VERSION) - expect(package.package_type).to eq('nuget') - end - - it_behaves_like 'assigns the package creator' - it_behaves_like 'assigns build to package' - it_behaves_like 'assigns status to package' - end -end diff --git a/spec/services/packages/nuget/update_package_from_metadata_service_spec.rb b/spec/services/packages/nuget/update_package_from_metadata_service_spec.rb index 92b493ed376..c1cce46a54c 100644 --- a/spec/services/packages/nuget/update_package_from_metadata_service_spec.rb +++ b/spec/services/packages/nuget/update_package_from_metadata_service_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Packages::Nuget::UpdatePackageFromMetadataService, :clean_gitlab_redis_shared_state do include ExclusiveLeaseHelpers - let(:package) { create(:nuget_package) } + let(:package) { create(:nuget_package, :processing) } let(:package_file) { package.package_files.first } let(:service) { described_class.new(package_file) } let(:package_name) { 'DummyProject.DummyPackage' } @@ -60,6 +60,7 @@ RSpec.describe Packages::Nuget::UpdatePackageFromMetadataService, :clean_gitlab_ .to change { ::Packages::Package.count }.by(0) .and change { Packages::DependencyLink.count }.by(0) expect(package_file.reload.file_name).not_to eq(package_file_name) + expect(package_file.package).to be_processing expect(package_file.package.reload.name).not_to eq(package_name) expect(package_file.package.version).not_to eq(package_version) end @@ -78,6 +79,7 @@ RSpec.describe Packages::Nuget::UpdatePackageFromMetadataService, :clean_gitlab_ expect(package.reload.name).to eq(package_name) expect(package.version).to eq(package_version) + expect(package).to be_default expect(package_file.reload.file_name).to eq(package_file_name) # hard reset needed to properly reload package_file.file expect(Packages::PackageFile.find(package_file.id).file.size).not_to eq 0 @@ -184,6 +186,7 @@ RSpec.describe Packages::Nuget::UpdatePackageFromMetadataService, :clean_gitlab_ expect(package.reload.name).to eq(package_name) expect(package.version).to eq(package_version) + expect(package).to be_default expect(package_file.reload.file_name).to eq(package_file_name) # hard reset needed to properly reload package_file.file expect(Packages::PackageFile.find(package_file.id).file.size).not_to eq 0 diff --git a/spec/services/packages/rubygems/dependency_resolver_service_spec.rb b/spec/services/packages/rubygems/dependency_resolver_service_spec.rb new file mode 100644 index 00000000000..206bffe53f8 --- /dev/null +++ b/spec/services/packages/rubygems/dependency_resolver_service_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Packages::Rubygems::DependencyResolverService do + let_it_be(:project) { create(:project, :private) } + let_it_be(:package) { create(:package, project: project) } + let_it_be(:user) { create(:user) } + let(:gem_name) { package.name } + let(:service) { described_class.new(project, user, gem_name: gem_name) } + + describe '#execute' do + subject { service.execute } + + context 'user without access' do + it 'returns a service error' do + expect(subject.error?).to be(true) + expect(subject.message).to eq('forbidden') + end + end + + context 'user with access' do + before do + project.add_developer(user) + end + + context 'when no package is found' do + let(:gem_name) { nil } + + it 'returns a service error', :aggregate_failures do + expect(subject.error?).to be(true) + expect(subject.message).to eq("#{gem_name} not found") + end + end + + context 'package without dependencies' do + it 'returns an empty dependencies array' do + expected_result = [{ + name: package.name, + number: package.version, + platform: described_class::DEFAULT_PLATFORM, + dependencies: [] + }] + + expect(subject.payload).to eq(expected_result) + end + end + + context 'package with dependencies' do + let(:dependency_link) { create(:packages_dependency_link, :rubygems, package: package)} + let(:dependency_link2) { create(:packages_dependency_link, :rubygems, package: package)} + let(:dependency_link3) { create(:packages_dependency_link, :rubygems, package: package)} + + it 'returns a set of dependencies' do + expected_result = [{ + name: package.name, + number: package.version, + platform: described_class::DEFAULT_PLATFORM, + dependencies: [ + [dependency_link.dependency.name, dependency_link.dependency.version_pattern], + [dependency_link2.dependency.name, dependency_link2.dependency.version_pattern], + [dependency_link3.dependency.name, dependency_link3.dependency.version_pattern] + ] + }] + + expect(subject.payload).to eq(expected_result) + end + end + + context 'package with multiple versions' do + let(:dependency_link) { create(:packages_dependency_link, :rubygems, package: package)} + let(:dependency_link2) { create(:packages_dependency_link, :rubygems, package: package)} + let(:dependency_link3) { create(:packages_dependency_link, :rubygems, package: package)} + let(:package2) { create(:package, project: project, name: package.name, version: '9.9.9') } + let(:dependency_link4) { create(:packages_dependency_link, :rubygems, package: package2)} + + it 'returns a set of dependencies' do + expected_result = [{ + name: package.name, + number: package.version, + platform: described_class::DEFAULT_PLATFORM, + dependencies: [ + [dependency_link.dependency.name, dependency_link.dependency.version_pattern], + [dependency_link2.dependency.name, dependency_link2.dependency.version_pattern], + [dependency_link3.dependency.name, dependency_link3.dependency.version_pattern] + ] + }, { + name: package2.name, + number: package2.version, + platform: described_class::DEFAULT_PLATFORM, + dependencies: [ + [dependency_link4.dependency.name, dependency_link4.dependency.version_pattern] + ] + }] + + expect(subject.payload).to eq(expected_result) + end + end + end + end +end diff --git a/spec/services/pages/legacy_storage_lease_spec.rb b/spec/services/pages/legacy_storage_lease_spec.rb index c022da6f47f..092dce093ff 100644 --- a/spec/services/pages/legacy_storage_lease_spec.rb +++ b/spec/services/pages/legacy_storage_lease_spec.rb @@ -47,14 +47,6 @@ RSpec.describe ::Pages::LegacyStorageLease do expect(service.execute).to eq(nil) end - - it 'runs guarded method if feature flag is disabled' do - stub_feature_flags(pages_use_legacy_storage_lease: false) - - expect(service).to receive(:execute_unsafe).and_call_original - - expect(service.execute).to eq(true) - end end context 'when another service holds the lease for the different project' do diff --git a/spec/services/projects/alerting/notify_service_spec.rb b/spec/services/projects/alerting/notify_service_spec.rb index 4e366fce0d9..c272ce13132 100644 --- a/spec/services/projects/alerting/notify_service_spec.rb +++ b/spec/services/projects/alerting/notify_service_spec.rb @@ -119,6 +119,7 @@ RSpec.describe Projects::Alerting::NotifyService do end it_behaves_like 'does not an create alert management alert' + it_behaves_like 'creates single system note based on the source of the alert' context 'auto_close_enabled setting enabled' do let(:auto_close_enabled) { true } @@ -131,6 +132,8 @@ RSpec.describe Projects::Alerting::NotifyService do expect(alert.ended_at).to eql(ended_at) end + it_behaves_like 'creates status-change system note for an auto-resolved alert' + context 'related issue exists' do let(:alert) { create(:alert_management_alert, :with_issue, project: project, fingerprint: fingerprint_sha) } let(:issue) { alert.issue } @@ -209,10 +212,7 @@ RSpec.describe Projects::Alerting::NotifyService do ) end - it 'creates a system note corresponding to alert creation' do - expect { subject }.to change(Note, :count).by(1) - expect(Note.last.note).to include(source) - end + it_behaves_like 'creates single system note based on the source of the alert' end end diff --git a/spec/services/projects/branches_by_mode_service_spec.rb b/spec/services/projects/branches_by_mode_service_spec.rb index 9199c3e0b3a..e8bcda8a9c4 100644 --- a/spec/services/projects/branches_by_mode_service_spec.rb +++ b/spec/services/projects/branches_by_mode_service_spec.rb @@ -20,7 +20,7 @@ RSpec.describe Projects::BranchesByModeService do branches, prev_page, next_page = subject - expect(branches.size).to eq(10) + expect(branches.size).to eq(11) expect(next_page).to be_nil expect(prev_page).to eq("/#{project.full_path}/-/branches/all?offset=2&page=3") end @@ -99,7 +99,7 @@ RSpec.describe Projects::BranchesByModeService do it 'returns branches after the specified branch' do branches, prev_page, next_page = subject - expect(branches.size).to eq(14) + expect(branches.size).to eq(15) expect(next_page).to be_nil expect(prev_page).to eq("/#{project.full_path}/-/branches/all?offset=3&page=4&sort=name_asc") end diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index f7da6f75141..306d87eefb8 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -349,27 +349,38 @@ RSpec.describe Projects::CreateService, '#execute' do context 'default visibility level' do let(:group) { create(:group, :private) } - before do - stub_application_setting(default_project_visibility: Gitlab::VisibilityLevel::INTERNAL) - group.add_developer(user) + using RSpec::Parameterized::TableSyntax - opts.merge!( - visibility: 'private', - name: 'test', - namespace: group, - path: 'foo' - ) + where(:case_name, :group_level, :project_level) do + [ + ['in public group', Gitlab::VisibilityLevel::PUBLIC, Gitlab::VisibilityLevel::INTERNAL], + ['in internal group', Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::INTERNAL], + ['in private group', Gitlab::VisibilityLevel::PRIVATE, Gitlab::VisibilityLevel::PRIVATE] + ] end - it 'creates a private project' do - project = create_project(user, opts) + with_them do + before do + stub_application_setting(default_project_visibility: Gitlab::VisibilityLevel::INTERNAL) + group.add_developer(user) + group.update!(visibility_level: group_level) - expect(project).to respond_to(:errors) + opts.merge!( + name: 'test', + namespace: group, + path: 'foo' + ) + end - expect(project.errors.any?).to be(false) - expect(project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) - expect(project.saved?).to be(true) - expect(project.valid?).to be(true) + it 'creates project with correct visibility level', :aggregate_failures do + project = create_project(user, opts) + + expect(project).to respond_to(:errors) + expect(project.errors).to be_blank + expect(project.visibility_level).to eq(project_level) + expect(project).to be_saved + expect(project).to be_valid + end end end diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb index 75d1c98923a..5410e784cc0 100644 --- a/spec/services/projects/destroy_service_spec.rb +++ b/spec/services/projects/destroy_service_spec.rb @@ -31,9 +31,34 @@ RSpec.describe Projects::DestroyService, :aggregate_failures do end shared_examples 'deleting the project with pipeline and build' do - context 'with pipeline and build', :sidekiq_inline do # which has optimistic locking + context 'with pipeline and build related records', :sidekiq_inline do # which has optimistic locking let!(:pipeline) { create(:ci_pipeline, project: project) } - let!(:build) { create(:ci_build, :artifacts, pipeline: pipeline) } + let!(:build) { create(:ci_build, :artifacts, :with_runner_session, pipeline: pipeline) } + let!(:trace_chunks) { create(:ci_build_trace_chunk, build: build) } + let!(:job_variables) { create(:ci_job_variable, job: build) } + let!(:report_result) { create(:ci_build_report_result, build: build) } + let!(:pending_state) { create(:ci_build_pending_state, build: build) } + + it 'deletes build related records' do + expect { destroy_project(project, user, {}) }.to change { Ci::Build.count }.by(-1) + .and change { Ci::BuildTraceChunk.count }.by(-1) + .and change { Ci::JobArtifact.count }.by(-2) + .and change { Ci::JobVariable.count }.by(-1) + .and change { Ci::BuildPendingState.count }.by(-1) + .and change { Ci::BuildReportResult.count }.by(-1) + .and change { Ci::BuildRunnerSession.count }.by(-1) + end + + it 'avoids N+1 queries', skip: 'skipped until fixed in https://gitlab.com/gitlab-org/gitlab/-/issues/24644' do + recorder = ActiveRecord::QueryRecorder.new { destroy_project(project, user, {}) } + + project = create(:project, :repository, namespace: user.namespace) + pipeline = create(:ci_pipeline, project: project) + builds = create_list(:ci_build, 3, :artifacts, pipeline: pipeline) + create_list(:ci_build_trace_chunk, 3, build: builds[0]) + + expect { destroy_project(project, project.owner, {}) }.not_to exceed_query_limit(recorder) + end it_behaves_like 'deleting the project' end @@ -60,357 +85,343 @@ RSpec.describe Projects::DestroyService, :aggregate_failures do end end - shared_examples 'project destroy' do - it_behaves_like 'deleting the project' + it_behaves_like 'deleting the project' - it 'invalidates personal_project_count cache' do - expect(user).to receive(:invalidate_personal_projects_count) + it 'invalidates personal_project_count cache' do + expect(user).to receive(:invalidate_personal_projects_count) - destroy_project(project, user, {}) + destroy_project(project, user, {}) + end + + it 'performs cancel for project ci pipelines' do + expect(::Ci::AbortProjectPipelinesService).to receive_message_chain(:new, :execute).with(project) + + destroy_project(project, user, {}) + end + + context 'when project has remote mirrors' do + let!(:project) do + create(:project, :repository, namespace: user.namespace).tap do |project| + project.remote_mirrors.create!(url: 'http://test.com') + end end - it 'performs cancel for project ci pipelines' do - expect(::Ci::AbortProjectPipelinesService).to receive_message_chain(:new, :execute).with(project) + it 'destroys them' do + expect(RemoteMirror.count).to eq(1) destroy_project(project, user, {}) + + expect(RemoteMirror.count).to eq(0) end + end - context 'when project has remote mirrors' do - let!(:project) do - create(:project, :repository, namespace: user.namespace).tap do |project| - project.remote_mirrors.create!(url: 'http://test.com') - end + context 'when project has exports' do + let!(:project_with_export) do + create(:project, :repository, namespace: user.namespace).tap do |project| + create(:import_export_upload, + project: project, + export_file: fixture_file_upload('spec/fixtures/project_export.tar.gz')) end + end - it 'destroys them' do - expect(RemoteMirror.count).to eq(1) - - destroy_project(project, user, {}) + it 'destroys project and export' do + expect do + destroy_project(project_with_export, user, {}) + end.to change(ImportExportUpload, :count).by(-1) - expect(RemoteMirror.count).to eq(0) - end + expect(Project.all).not_to include(project_with_export) end + end - context 'when project has exports' do - let!(:project_with_export) do - create(:project, :repository, namespace: user.namespace).tap do |project| - create(:import_export_upload, - project: project, - export_file: fixture_file_upload('spec/fixtures/project_export.tar.gz')) - end - end + context 'Sidekiq fake' do + before do + # Dont run sidekiq to check if renamed repository exists + Sidekiq::Testing.fake! { destroy_project(project, user, {}) } + end - it 'destroys project and export' do - expect do - destroy_project(project_with_export, user, {}) - end.to change(ImportExportUpload, :count).by(-1) + it { expect(Project.all).not_to include(project) } - expect(Project.all).not_to include(project_with_export) - end + it do + expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_falsey end - context 'Sidekiq fake' do - before do - # Dont run sidekiq to check if renamed repository exists - Sidekiq::Testing.fake! { destroy_project(project, user, {}) } - end + it do + expect(project.gitlab_shell.repository_exists?(project.repository_storage, remove_path + '.git')).to be_truthy + end + end - it { expect(Project.all).not_to include(project) } + context 'when flushing caches fail due to Git errors' do + before do + allow(project.repository).to receive(:before_delete).and_raise(::Gitlab::Git::CommandError) + allow(Gitlab::GitLogger).to receive(:warn).with( + class: Repositories::DestroyService.name, + container_id: project.id, + disk_path: project.disk_path, + message: 'Gitlab::Git::CommandError').and_call_original + end - it do - expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_falsey - end + it_behaves_like 'deleting the project' + end - it do - expect(project.gitlab_shell.repository_exists?(project.repository_storage, remove_path + '.git')).to be_truthy - end + context 'when flushing caches fail due to Redis' do + before do + new_user = create(:user) + project.team.add_user(new_user, Gitlab::Access::DEVELOPER) + allow_any_instance_of(described_class).to receive(:flush_caches).and_raise(::Redis::CannotConnectError) end - context 'when flushing caches fail due to Git errors' do - before do - allow(project.repository).to receive(:before_delete).and_raise(::Gitlab::Git::CommandError) - allow(Gitlab::GitLogger).to receive(:warn).with( - class: Repositories::DestroyService.name, - container_id: project.id, - disk_path: project.disk_path, - message: 'Gitlab::Git::CommandError').and_call_original + it 'keeps project team intact upon an error' do + perform_enqueued_jobs do + destroy_project(project, user, {}) + rescue ::Redis::CannotConnectError end - it_behaves_like 'deleting the project' + expect(project.team.members.count).to eq 2 end + end + + context 'with async_execute', :sidekiq_inline do + let(:async) { true } - context 'when flushing caches fail due to Redis' do + context 'async delete of project with private issue visibility' do before do - new_user = create(:user) - project.team.add_user(new_user, Gitlab::Access::DEVELOPER) - allow_any_instance_of(described_class).to receive(:flush_caches).and_raise(::Redis::CannotConnectError) + project.project_feature.update_attribute("issues_access_level", ProjectFeature::PRIVATE) end - it 'keeps project team intact upon an error' do - perform_enqueued_jobs do - destroy_project(project, user, {}) - rescue ::Redis::CannotConnectError - end - - expect(project.team.members.count).to eq 2 - end + it_behaves_like 'deleting the project' end - context 'with async_execute', :sidekiq_inline do - let(:async) { true } + it_behaves_like 'deleting the project with pipeline and build' - context 'async delete of project with private issue visibility' do + context 'errors' do + context 'when `remove_legacy_registry_tags` fails' do before do - project.project_feature.update_attribute("issues_access_level", ProjectFeature::PRIVATE) + expect_any_instance_of(described_class) + .to receive(:remove_legacy_registry_tags).and_return(false) end - it_behaves_like 'deleting the project' + it_behaves_like 'handles errors thrown during async destroy', "Failed to remove some tags" end - it_behaves_like 'deleting the project with pipeline and build' - - context 'errors' do - context 'when `remove_legacy_registry_tags` fails' do - before do - expect_any_instance_of(described_class) - .to receive(:remove_legacy_registry_tags).and_return(false) - end - - it_behaves_like 'handles errors thrown during async destroy', "Failed to remove some tags" + context 'when `remove_repository` fails' do + before do + expect_any_instance_of(described_class) + .to receive(:remove_repository).and_return(false) end - context 'when `remove_repository` fails' do - before do - expect_any_instance_of(described_class) - .to receive(:remove_repository).and_return(false) - end + it_behaves_like 'handles errors thrown during async destroy', "Failed to remove project repository" + end - it_behaves_like 'handles errors thrown during async destroy', "Failed to remove project repository" + context 'when `execute` raises expected error' do + before do + expect_any_instance_of(Project) + .to receive(:destroy!).and_raise(StandardError.new("Other error message")) end - context 'when `execute` raises expected error' do - before do - expect_any_instance_of(Project) - .to receive(:destroy!).and_raise(StandardError.new("Other error message")) - end + it_behaves_like 'handles errors thrown during async destroy', "Other error message" + end - it_behaves_like 'handles errors thrown during async destroy', "Other error message" + context 'when `execute` raises unexpected error' do + before do + expect_any_instance_of(Project) + .to receive(:destroy!).and_raise(Exception.new('Other error message')) end - context 'when `execute` raises unexpected error' do - before do - expect_any_instance_of(Project) - .to receive(:destroy!).and_raise(Exception.new('Other error message')) - end + it 'allows error to bubble up and rolls back project deletion' do + expect do + destroy_project(project, user, {}) + end.to raise_error(Exception, 'Other error message') - it 'allows error to bubble up and rolls back project deletion' do - expect do - destroy_project(project, user, {}) - end.to raise_error(Exception, 'Other error message') - - expect(project.reload.pending_delete).to be(false) - expect(project.delete_error).to include("Other error message") - end + expect(project.reload.pending_delete).to be(false) + expect(project.delete_error).to include("Other error message") end end end + end - describe 'container registry' do - context 'when there are regular container repositories' do - let(:container_repository) { create(:container_repository) } + describe 'container registry' do + context 'when there are regular container repositories' do + let(:container_repository) { create(:container_repository) } - before do - stub_container_registry_tags(repository: project.full_path + '/image', - tags: ['tag']) - project.container_repositories << container_repository - end + before do + stub_container_registry_tags(repository: project.full_path + '/image', + tags: ['tag']) + project.container_repositories << container_repository + end - context 'when image repository deletion succeeds' do - it 'removes tags' do - expect_any_instance_of(ContainerRepository) - .to receive(:delete_tags!).and_return(true) + context 'when image repository deletion succeeds' do + it 'removes tags' do + expect_any_instance_of(ContainerRepository) + .to receive(:delete_tags!).and_return(true) - destroy_project(project, user) - end + destroy_project(project, user) end + end - context 'when image repository deletion fails' do - it 'raises an exception' do - expect_any_instance_of(ContainerRepository) - .to receive(:delete_tags!).and_raise(RuntimeError) + context 'when image repository deletion fails' do + it 'raises an exception' do + expect_any_instance_of(ContainerRepository) + .to receive(:delete_tags!).and_raise(RuntimeError) - expect(destroy_project(project, user)).to be false - end + expect(destroy_project(project, user)).to be false end + end - context 'when registry is disabled' do - before do - stub_container_registry_config(enabled: false) - end + context 'when registry is disabled' do + before do + stub_container_registry_config(enabled: false) + end - it 'does not attempting to remove any tags' do - expect(Projects::ContainerRepository::DestroyService).not_to receive(:new) + it 'does not attempting to remove any tags' do + expect(Projects::ContainerRepository::DestroyService).not_to receive(:new) - destroy_project(project, user) - end + destroy_project(project, user) end end + end - context 'when there are tags for legacy root repository' do - before do - stub_container_registry_tags(repository: project.full_path, - tags: ['tag']) - end + context 'when there are tags for legacy root repository' do + before do + stub_container_registry_tags(repository: project.full_path, + tags: ['tag']) + end - context 'when image repository tags deletion succeeds' do - it 'removes tags' do - expect_any_instance_of(ContainerRepository) - .to receive(:delete_tags!).and_return(true) + context 'when image repository tags deletion succeeds' do + it 'removes tags' do + expect_any_instance_of(ContainerRepository) + .to receive(:delete_tags!).and_return(true) - destroy_project(project, user) - end + destroy_project(project, user) end + end - context 'when image repository tags deletion fails' do - it 'raises an exception' do - expect_any_instance_of(ContainerRepository) - .to receive(:delete_tags!).and_return(false) + context 'when image repository tags deletion fails' do + it 'raises an exception' do + expect_any_instance_of(ContainerRepository) + .to receive(:delete_tags!).and_return(false) - expect(destroy_project(project, user)).to be false - end + expect(destroy_project(project, user)).to be false end end end + end - context 'for a forked project with LFS objects' do - let(:forked_project) { fork_project(project, user) } + context 'for a forked project with LFS objects' do + let(:forked_project) { fork_project(project, user) } - before do - project.lfs_objects << create(:lfs_object) - forked_project.reload - end + before do + project.lfs_objects << create(:lfs_object) + forked_project.reload + end - it 'destroys the fork' do - expect { destroy_project(forked_project, user) } - .not_to raise_error - end + it 'destroys the fork' do + expect { destroy_project(forked_project, user) } + .not_to raise_error end + end - context 'as the root of a fork network' do - let!(:fork_1) { fork_project(project, user) } - let!(:fork_2) { fork_project(project, user) } + context 'as the root of a fork network' do + let!(:fork_1) { fork_project(project, user) } + let!(:fork_2) { fork_project(project, user) } - it 'updates the fork network with the project name' do - fork_network = project.fork_network + it 'updates the fork network with the project name' do + fork_network = project.fork_network - destroy_project(project, user) + destroy_project(project, user) - fork_network.reload + fork_network.reload - expect(fork_network.deleted_root_project_name).to eq(project.full_name) - expect(fork_network.root_project).to be_nil - end + expect(fork_network.deleted_root_project_name).to eq(project.full_name) + expect(fork_network.root_project).to be_nil end + end - context 'repository +deleted path removal' do - context 'regular phase' do - it 'schedules +deleted removal of existing repos' do - service = described_class.new(project, user, {}) - allow(service).to receive(:schedule_stale_repos_removal) + context 'repository +deleted path removal' do + context 'regular phase' do + it 'schedules +deleted removal of existing repos' do + service = described_class.new(project, user, {}) + allow(service).to receive(:schedule_stale_repos_removal) - expect(Repositories::ShellDestroyService).to receive(:new).and_call_original - expect(GitlabShellWorker).to receive(:perform_in) - .with(5.minutes, :remove_repository, project.repository_storage, removal_path(project.disk_path)) + expect(Repositories::ShellDestroyService).to receive(:new).and_call_original + expect(GitlabShellWorker).to receive(:perform_in) + .with(5.minutes, :remove_repository, project.repository_storage, removal_path(project.disk_path)) - service.execute - end + service.execute end + end - context 'stale cleanup' do - let(:async) { true } + context 'stale cleanup' do + let(:async) { true } - it 'schedules +deleted wiki and repo removal' do - allow(ProjectDestroyWorker).to receive(:perform_async) + it 'schedules +deleted wiki and repo removal' do + allow(ProjectDestroyWorker).to receive(:perform_async) - expect(Repositories::ShellDestroyService).to receive(:new).with(project.repository).and_call_original - expect(GitlabShellWorker).to receive(:perform_in) - .with(10.minutes, :remove_repository, project.repository_storage, removal_path(project.disk_path)) + expect(Repositories::ShellDestroyService).to receive(:new).with(project.repository).and_call_original + expect(GitlabShellWorker).to receive(:perform_in) + .with(10.minutes, :remove_repository, project.repository_storage, removal_path(project.disk_path)) - expect(Repositories::ShellDestroyService).to receive(:new).with(project.wiki.repository).and_call_original - expect(GitlabShellWorker).to receive(:perform_in) - .with(10.minutes, :remove_repository, project.repository_storage, removal_path(project.wiki.disk_path)) + expect(Repositories::ShellDestroyService).to receive(:new).with(project.wiki.repository).and_call_original + expect(GitlabShellWorker).to receive(:perform_in) + .with(10.minutes, :remove_repository, project.repository_storage, removal_path(project.wiki.disk_path)) - destroy_project(project, user, {}) - end + destroy_project(project, user, {}) end end + end - context 'snippets' do - let!(:snippet1) { create(:project_snippet, project: project, author: user) } - let!(:snippet2) { create(:project_snippet, project: project, author: user) } - - it 'does not include snippets when deleting in batches' do - expect(project).to receive(:destroy_dependent_associations_in_batches).with({ exclude: [:container_repositories, :snippets] }) + context 'snippets' do + let!(:snippet1) { create(:project_snippet, project: project, author: user) } + let!(:snippet2) { create(:project_snippet, project: project, author: user) } - destroy_project(project, user) - end + it 'does not include snippets when deleting in batches' do + expect(project).to receive(:destroy_dependent_associations_in_batches).with({ exclude: [:container_repositories, :snippets] }) - it 'calls the bulk snippet destroy service' do - expect(project.snippets.count).to eq 2 + destroy_project(project, user) + end - expect(Snippets::BulkDestroyService).to receive(:new) - .with(user, project.snippets).and_call_original + it 'calls the bulk snippet destroy service' do + expect(project.snippets.count).to eq 2 - expect do - destroy_project(project, user) - end.to change(Snippet, :count).by(-2) - end + expect(Snippets::BulkDestroyService).to receive(:new) + .with(user, project.snippets).and_call_original - context 'when an error is raised deleting snippets' do - it 'does not delete project' do - allow_next_instance_of(Snippets::BulkDestroyService) do |instance| - allow(instance).to receive(:execute).and_return(ServiceResponse.error(message: 'foo')) - end - - expect(destroy_project(project, user)).to be_falsey - expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_truthy - end - end + expect do + destroy_project(project, user) + end.to change(Snippet, :count).by(-2) end - context 'error while destroying', :sidekiq_inline do - let!(:pipeline) { create(:ci_pipeline, project: project) } - let!(:builds) { create_list(:ci_build, 2, :artifacts, pipeline: pipeline) } - let!(:build_trace) { create(:ci_build_trace_chunk, build: builds[0]) } - - it 'deletes on retry' do - # We can expect this to timeout for very large projects - # TODO: remove allow_next_instance_of: https://gitlab.com/gitlab-org/gitlab/-/issues/220440 - allow_any_instance_of(Ci::Build).to receive(:destroy).and_raise('boom') - destroy_project(project, user, {}) - - allow_any_instance_of(Ci::Build).to receive(:destroy).and_call_original - destroy_project(project, user, {}) + context 'when an error is raised deleting snippets' do + it 'does not delete project' do + allow_next_instance_of(Snippets::BulkDestroyService) do |instance| + allow(instance).to receive(:execute).and_return(ServiceResponse.error(message: 'foo')) + end - expect(Project.unscoped.all).not_to include(project) - expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_falsey - expect(project.gitlab_shell.repository_exists?(project.repository_storage, remove_path + '.git')).to be_falsey - expect(project.all_pipelines).to be_empty - expect(project.builds).to be_empty + expect(destroy_project(project, user)).to be_falsey + expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_truthy end end end - context 'when project_transactionless_destroy enabled' do - it_behaves_like 'project destroy' - end + context 'error while destroying', :sidekiq_inline do + let!(:pipeline) { create(:ci_pipeline, project: project) } + let!(:builds) { create_list(:ci_build, 2, :artifacts, pipeline: pipeline) } + let!(:build_trace) { create(:ci_build_trace_chunk, build: builds[0]) } - context 'when project_transactionless_destroy disabled', :sidekiq_inline do - before do - stub_feature_flags(project_transactionless_destroy: false) - end + it 'deletes on retry' do + # We can expect this to timeout for very large projects + # TODO: remove allow_next_instance_of: https://gitlab.com/gitlab-org/gitlab/-/issues/220440 + allow_any_instance_of(Ci::Build).to receive(:destroy).and_raise('boom') + destroy_project(project, user, {}) + + allow_any_instance_of(Ci::Build).to receive(:destroy).and_call_original + destroy_project(project, user, {}) - it_behaves_like 'project destroy' + expect(Project.unscoped.all).not_to include(project) + expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_falsey + expect(project.gitlab_shell.repository_exists?(project.repository_storage, remove_path + '.git')).to be_falsey + expect(project.all_pipelines).to be_empty + expect(project.builds).to be_empty + end end def destroy_project(project, user, params = {}) diff --git a/spec/services/projects/schedule_bulk_repository_shard_moves_service_spec.rb b/spec/services/projects/schedule_bulk_repository_shard_moves_service_spec.rb index 15c9d1e5925..2dc4a56368b 100644 --- a/spec/services/projects/schedule_bulk_repository_shard_moves_service_spec.rb +++ b/spec/services/projects/schedule_bulk_repository_shard_moves_service_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Projects::ScheduleBulkRepositoryShardMovesService do it_behaves_like 'moves repository shard in bulk' do let_it_be_with_reload(:container) { create(:project, :repository).tap { |project| project.track_project_repository } } - let(:move_service_klass) { ProjectRepositoryStorageMove } - let(:bulk_worker_klass) { ::ProjectScheduleBulkRepositoryShardMovesWorker } + let(:move_service_klass) { Projects::RepositoryStorageMove } + let(:bulk_worker_klass) { ::Projects::ScheduleBulkRepositoryShardMovesWorker } end end diff --git a/spec/services/projects/update_pages_configuration_service_spec.rb b/spec/services/projects/update_pages_configuration_service_spec.rb index 294de813e02..9ef66a10f0d 100644 --- a/spec/services/projects/update_pages_configuration_service_spec.rb +++ b/spec/services/projects/update_pages_configuration_service_spec.rb @@ -26,11 +26,18 @@ RSpec.describe Projects::UpdatePagesConfigurationService do context 'when configuration changes' do it 'updates the config and reloads the daemon' do - allow(service).to receive(:update_file).and_call_original - expect(service).to receive(:update_file).with(file.path, an_instance_of(String)) .and_call_original - expect(service).to receive(:reload_daemon).and_call_original + allow(service).to receive(:update_file).with(File.join(::Settings.pages.path, '.update'), + an_instance_of(String)).and_call_original + + expect(subject).to include(status: :success) + end + + it "doesn't update configuration files if updates on legacy storage are disabled" do + stub_feature_flags(pages_update_legacy_storage: false) + + expect(service).not_to receive(:update_file) expect(subject).to include(status: :success) end @@ -42,8 +49,8 @@ RSpec.describe Projects::UpdatePagesConfigurationService do service.execute end - it 'does not update the .update file' do - expect(service).not_to receive(:reload_daemon) + it 'does not update anything' do + expect(service).not_to receive(:update_file) expect(subject).to include(status: :success) end diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb index 6bf2876f640..b735f4b6bc2 100644 --- a/spec/services/projects/update_pages_service_spec.rb +++ b/spec/services/projects/update_pages_service_spec.rb @@ -335,6 +335,41 @@ RSpec.describe Projects::UpdatePagesService do end end + context 'when retrying the job' do + let!(:older_deploy_job) do + create(:generic_commit_status, :failed, pipeline: pipeline, + ref: build.ref, + stage: 'deploy', + name: 'pages:deploy') + end + + before do + create(:ci_job_artifact, :correct_checksum, file: file, job: build) + create(:ci_job_artifact, file_type: :metadata, file_format: :gzip, file: metadata, job: build) + build.reload + end + + it 'marks older pages:deploy jobs retried' do + expect(execute).to eq(:success) + + expect(older_deploy_job.reload).to be_retried + end + + context 'when FF ci_fix_commit_status_retried is disabled' do + before do + stub_feature_flags(ci_fix_commit_status_retried: false) + end + + it 'does not mark older pages:deploy jobs retried' do + expect(execute).to eq(:success) + + expect(older_deploy_job.reload).not_to be_retried + end + end + end + + private + def deploy_status GenericCommitStatus.find_by(name: 'pages:deploy') end diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index a59b6adf346..b9e909e8615 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -551,7 +551,7 @@ RSpec.describe Projects::UpdateService do expect(project).to be_repository_read_only expect(project.repository_storage_moves.last).to have_attributes( - state: ::ProjectRepositoryStorageMove.state_machines[:state].states[:scheduled].value, + state: ::Projects::RepositoryStorageMove.state_machines[:state].states[:scheduled].value, source_storage_name: 'default', destination_storage_name: 'test_second_storage' ) diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb index 1a102b125f6..bf35e72a037 100644 --- a/spec/services/quick_actions/interpret_service_spec.rb +++ b/spec/services/quick_actions/interpret_service_spec.rb @@ -1949,6 +1949,100 @@ RSpec.describe QuickActions::InterpretService do end end end + + context 'invite_email command' do + let_it_be(:issuable) { issue } + + it_behaves_like 'empty command', "No email participants were added. Either none were provided, or they already exist." do + let(:content) { '/invite_email' } + end + + context 'with existing email participant' do + let(:content) { '/invite_email a@gitlab.com' } + + before do + issuable.issue_email_participants.create!(email: "a@gitlab.com") + end + + it_behaves_like 'empty command', "No email participants were added. Either none were provided, or they already exist." + end + + context 'with new email participants' do + let(:content) { '/invite_email a@gitlab.com b@gitlab.com' } + + subject(:add_emails) { service.execute(content, issuable) } + + it 'returns message' do + _, _, message = add_emails + + expect(message).to eq('Added a@gitlab.com and b@gitlab.com.') + end + + it 'adds 2 participants' do + expect { add_emails }.to change { issue.issue_email_participants.count }.by(2) + end + + context 'with mixed case email' do + let(:content) { '/invite_email FirstLast@GitLab.com' } + + it 'returns correctly cased message' do + _, _, message = add_emails + + expect(message).to eq('Added FirstLast@GitLab.com.') + end + end + + context 'with invalid email' do + let(:content) { '/invite_email a@gitlab.com bad_email' } + + it 'only adds valid emails' do + expect { add_emails }.to change { issue.issue_email_participants.count }.by(1) + end + end + + context 'with existing email' do + let(:content) { '/invite_email a@gitlab.com existing@gitlab.com' } + + it 'only adds new emails' do + issue.issue_email_participants.create!(email: 'existing@gitlab.com') + + expect { add_emails }.to change { issue.issue_email_participants.count }.by(1) + end + + it 'only adds new (case insensitive) emails' do + issue.issue_email_participants.create!(email: 'EXISTING@gitlab.com') + + expect { add_emails }.to change { issue.issue_email_participants.count }.by(1) + end + end + + context 'with duplicate email' do + let(:content) { '/invite_email a@gitlab.com a@gitlab.com' } + + it 'only adds unique new emails' do + expect { add_emails }.to change { issue.issue_email_participants.count }.by(1) + end + end + + context 'with more than 6 emails' do + let(:content) { '/invite_email a@gitlab.com b@gitlab.com c@gitlab.com d@gitlab.com e@gitlab.com f@gitlab.com g@gitlab.com' } + + it 'only adds 6 new emails' do + expect { add_emails }.to change { issue.issue_email_participants.count }.by(6) + end + end + + context 'with feature flag disabled' do + before do + stub_feature_flags(issue_email_participants: false) + end + + it 'does not add any participants' do + expect { add_emails }.not_to change { issue.issue_email_participants.count } + end + end + end + end end describe '#explain' do diff --git a/spec/services/repositories/changelog_service_spec.rb b/spec/services/repositories/changelog_service_spec.rb index a545b0f070a..dab38445ccf 100644 --- a/spec/services/repositories/changelog_service_spec.rb +++ b/spec/services/repositories/changelog_service_spec.rb @@ -4,48 +4,64 @@ require 'spec_helper' RSpec.describe Repositories::ChangelogService do describe '#execute' do - it 'generates and commits a changelog section' do - project = create(:project, :empty_repo) - creator = project.creator - author1 = create(:user) - author2 = create(:user) - - project.add_maintainer(author1) - project.add_maintainer(author2) - - mr1 = create(:merge_request, :merged, target_project: project) - mr2 = create(:merge_request, :merged, target_project: project) - - # The range of commits ignores the first commit, but includes the last - # commit. To ensure both the commits below are included, we must create an - # extra commit. - # - # In the real world, the start commit of the range will be the last commit - # of the previous release, so ignoring that is expected and desired. - sha1 = create_commit( + let!(:project) { create(:project, :empty_repo) } + let!(:creator) { project.creator } + let!(:author1) { create(:user) } + let!(:author2) { create(:user) } + let!(:mr1) { create(:merge_request, :merged, target_project: project) } + let!(:mr2) { create(:merge_request, :merged, target_project: project) } + + # The range of commits ignores the first commit, but includes the last + # commit. To ensure both the commits below are included, we must create an + # extra commit. + # + # In the real world, the start commit of the range will be the last commit + # of the previous release, so ignoring that is expected and desired. + let!(:sha1) do + create_commit( project, creator, commit_message: 'Initial commit', actions: [{ action: 'create', content: 'test', file_path: 'README.md' }] ) + end + + let!(:sha2) do + project.add_maintainer(author1) - sha2 = create_commit( + create_commit( project, author1, commit_message: "Title 1\n\nChangelog: feature", actions: [{ action: 'create', content: 'foo', file_path: 'a.txt' }] ) + end + + let!(:sha3) do + project.add_maintainer(author2) - sha3 = create_commit( + create_commit( project, author2, commit_message: "Title 2\n\nChangelog: feature", actions: [{ action: 'create', content: 'bar', file_path: 'b.txt' }] ) + end - commit1 = project.commit(sha2) - commit2 = project.commit(sha3) + let!(:sha4) do + create_commit( + project, + author2, + commit_message: "Title 3\n\nChangelog: feature", + actions: [{ action: 'create', content: 'bar', file_path: 'c.txt' }] + ) + end + let!(:commit1) { project.commit(sha2) } + let!(:commit2) { project.commit(sha3) } + let!(:commit3) { project.commit(sha4) } + + it 'generates and commits a changelog section' do allow(MergeRequestDiffCommit) .to receive(:oldest_merge_request_id_per_commit) .with(project.id, [commit2.id, commit1.id]) @@ -54,16 +70,60 @@ RSpec.describe Repositories::ChangelogService do { sha: sha3, merge_request_id: mr2.id } ]) - recorder = ActiveRecord::QueryRecorder.new do - described_class - .new(project, creator, version: '1.0.0', from: sha1, to: sha3) - .execute - end + service = described_class + .new(project, creator, version: '1.0.0', from: sha1, to: sha3) + + recorder = ActiveRecord::QueryRecorder.new { service.execute } + changelog = project.repository.blob_at('master', 'CHANGELOG.md')&.data + + expect(recorder.count).to eq(11) + expect(changelog).to include('Title 1', 'Title 2') + end + + it "ignores a commit when it's both added and reverted in the same range" do + create_commit( + project, + author2, + commit_message: "Title 4\n\nThis reverts commit #{sha4}", + actions: [{ action: 'create', content: 'bar', file_path: 'd.txt' }] + ) + + described_class + .new(project, creator, version: '1.0.0', from: sha1) + .execute changelog = project.repository.blob_at('master', 'CHANGELOG.md')&.data - expect(recorder.count).to eq(10) expect(changelog).to include('Title 1', 'Title 2') + expect(changelog).not_to include('Title 3', 'Title 4') + end + + it 'includes a revert commit when it has a trailer' do + create_commit( + project, + author2, + commit_message: "Title 4\n\nThis reverts commit #{sha4}\n\nChangelog: added", + actions: [{ action: 'create', content: 'bar', file_path: 'd.txt' }] + ) + + described_class + .new(project, creator, version: '1.0.0', from: sha1) + .execute + + changelog = project.repository.blob_at('master', 'CHANGELOG.md')&.data + + expect(changelog).to include('Title 1', 'Title 2', 'Title 4') + expect(changelog).not_to include('Title 3') + end + + it 'uses the target branch when "to" is unspecified' do + described_class + .new(project, creator, version: '1.0.0', from: sha1) + .execute + + changelog = project.repository.blob_at('master', 'CHANGELOG.md')&.data + + expect(changelog).to include('Title 1', 'Title 2', 'Title 3') end end diff --git a/spec/services/snippets/schedule_bulk_repository_shard_moves_service_spec.rb b/spec/services/snippets/schedule_bulk_repository_shard_moves_service_spec.rb index 764c7f94a46..9286d73ed4a 100644 --- a/spec/services/snippets/schedule_bulk_repository_shard_moves_service_spec.rb +++ b/spec/services/snippets/schedule_bulk_repository_shard_moves_service_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Snippets::ScheduleBulkRepositoryShardMovesService do it_behaves_like 'moves repository shard in bulk' do let_it_be_with_reload(:container) { create(:snippet, :repository) } - let(:move_service_klass) { SnippetRepositoryStorageMove } - let(:bulk_worker_klass) { ::SnippetScheduleBulkRepositoryShardMovesWorker } + let(:move_service_klass) { Snippets::RepositoryStorageMove } + let(:bulk_worker_klass) { ::Snippets::ScheduleBulkRepositoryShardMovesWorker } end end diff --git a/spec/services/system_hooks_service_spec.rb b/spec/services/system_hooks_service_spec.rb index 1ec5237370f..446325e5f71 100644 --- a/spec/services/system_hooks_service_spec.rb +++ b/spec/services/system_hooks_service_spec.rb @@ -149,9 +149,6 @@ RSpec.describe SystemHooksService do it { expect(event_name(project, :rename)).to eq "project_rename" } it { expect(event_name(project, :transfer)).to eq "project_transfer" } it { expect(event_name(project, :update)).to eq "project_update" } - it { expect(event_name(project_member, :create)).to eq "user_add_to_team" } - it { expect(event_name(project_member, :destroy)).to eq "user_remove_from_team" } - it { expect(event_name(project_member, :update)).to eq "user_update_for_team" } it { expect(event_name(key, :create)).to eq 'key_create' } it { expect(event_name(key, :destroy)).to eq 'key_destroy' } end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index df4880dfa13..54cef164f1c 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -779,4 +779,17 @@ RSpec.describe SystemNoteService do described_class.change_incident_severity(incident, author) end end + + describe '.log_resolving_alert' do + let(:alert) { build(:alert_management_alert) } + let(:monitoring_tool) { 'Prometheus' } + + it 'calls AlertManagementService' do + expect_next_instance_of(SystemNotes::AlertManagementService) do |service| + expect(service).to receive(:log_resolving_alert).with(monitoring_tool) + end + + described_class.log_resolving_alert(alert, monitoring_tool) + end + end end diff --git a/spec/services/system_notes/alert_management_service_spec.rb b/spec/services/system_notes/alert_management_service_spec.rb index 4ebaa54534c..fc71799d8c5 100644 --- a/spec/services/system_notes/alert_management_service_spec.rb +++ b/spec/services/system_notes/alert_management_service_spec.rb @@ -59,4 +59,17 @@ RSpec.describe ::SystemNotes::AlertManagementService do expect(subject.note).to eq("changed the status to **Resolved** by closing issue #{issue.to_reference(project)}") end end + + describe '#log_resolving_alert' do + subject { described_class.new(noteable: noteable, project: project).log_resolving_alert('Some Service') } + + it_behaves_like 'a system note' do + let(:author) { User.alert_bot } + let(:action) { 'new_alert_added' } + end + + it 'has the appropriate message' do + expect(subject.note).to eq('logged a resolving alert from **Some Service**') + end + end end diff --git a/spec/services/system_notes/merge_requests_service_spec.rb b/spec/services/system_notes/merge_requests_service_spec.rb index 2131f3d3bdf..58d2489f878 100644 --- a/spec/services/system_notes/merge_requests_service_spec.rb +++ b/spec/services/system_notes/merge_requests_service_spec.rb @@ -189,7 +189,7 @@ RSpec.describe ::SystemNotes::MergeRequestsService do subject { service.change_branch('target', 'delete', old_branch, new_branch) } it 'sets the note text' do - expect(subject.note).to eq "changed automatically target branch to `#{new_branch}` because `#{old_branch}` was deleted" + expect(subject.note).to eq "deleted the `#{old_branch}` branch. This merge request now targets the `#{new_branch}` branch" end end diff --git a/spec/services/users/dismiss_user_callout_service_spec.rb b/spec/services/users/dismiss_user_callout_service_spec.rb new file mode 100644 index 00000000000..22f84a939f7 --- /dev/null +++ b/spec/services/users/dismiss_user_callout_service_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Users::DismissUserCalloutService do + let(:user) { create(:user) } + + let(:service) do + described_class.new( + container: nil, current_user: user, params: { feature_name: UserCallout.feature_names.each_key.first } + ) + end + + describe '#execute' do + subject(:execute) { service.execute } + + it 'returns a user callout' do + expect(execute).to be_an_instance_of(UserCallout) + end + + it 'sets the dismisse_at attribute to current time' do + freeze_time do + expect(execute).to have_attributes(dismissed_at: Time.current) + end + end + end +end diff --git a/spec/services/users/refresh_authorized_projects_service_spec.rb b/spec/services/users/refresh_authorized_projects_service_spec.rb index cc01b22f9d2..1e74ff3d9eb 100644 --- a/spec/services/users/refresh_authorized_projects_service_spec.rb +++ b/spec/services/users/refresh_authorized_projects_service_spec.rb @@ -152,9 +152,13 @@ RSpec.describe Users::RefreshAuthorizedProjectsService do expect(Gitlab::AppJsonLogger).to( receive(:info) .with(event: 'authorized_projects_refresh', + user_id: user.id, 'authorized_projects_refresh.source': source, - 'authorized_projects_refresh.rows_deleted': 0, - 'authorized_projects_refresh.rows_added': 1)) + 'authorized_projects_refresh.rows_deleted_count': 0, + 'authorized_projects_refresh.rows_added_count': 1, + 'authorized_projects_refresh.rows_deleted_slice': [], + 'authorized_projects_refresh.rows_added_slice': [[user.id, project.id, Gitlab::Access::MAINTAINER]]) + ) service.update_authorizations([], [[user.id, project.id, Gitlab::Access::MAINTAINER]]) end diff --git a/spec/spam/concerns/has_spam_action_response_fields_spec.rb b/spec/spam/concerns/has_spam_action_response_fields_spec.rb new file mode 100644 index 00000000000..4d5f8d9d431 --- /dev/null +++ b/spec/spam/concerns/has_spam_action_response_fields_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Spam::Concerns::HasSpamActionResponseFields do + subject do + klazz = Class.new + klazz.include described_class + klazz.new + end + + describe '#with_spam_action_response_fields' do + let(:spam_log) { double(:spam_log, id: 1) } + let(:spammable) { double(:spammable, spam?: true, render_recaptcha?: true, spam_log: spam_log) } + let(:recaptcha_site_key) { 'abc123' } + + before do + allow(Gitlab::CurrentSettings).to receive(:recaptcha_site_key) { recaptcha_site_key } + end + + it 'merges in spam action fields from spammable' do + result = subject.send(:with_spam_action_response_fields, spammable) do + { other_field: true } + end + expect(result) + .to eq({ + spam: true, + needs_captcha_response: true, + spam_log_id: 1, + captcha_site_key: recaptcha_site_key, + other_field: true + }) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 64c1479a412..60a8fb8cb9f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -180,6 +180,8 @@ RSpec.configure do |config| end if ENV['FLAKY_RSPEC_GENERATE_REPORT'] + require_relative '../tooling/rspec_flaky/listener' + config.reporter.register_listener( RspecFlaky::Listener.new, :example_passed, @@ -244,14 +246,21 @@ RSpec.configure do |config| stub_feature_flags(unified_diff_components: false) + # Disable this feature flag as we iterate and + # refactor filtered search to use gitlab ui + # components to meet feature parody. More details found + # https://gitlab.com/groups/gitlab-org/-/epics/5501 + stub_feature_flags(boards_filtered_search: false) + + # The following `vue_issues_list` stub can be removed once the + # Vue issues page has feature parity with the current Haml page + stub_feature_flags(vue_issues_list: false) + allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged) else unstub_all_feature_flags end - # Enable Marginalia feature for all specs in the test suite. - Gitlab::Marginalia.enabled = true - # Stub these calls due to being expensive operations # It can be reenabled for specific tests via: # diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index db198ac9808..be2b41d6997 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -79,8 +79,30 @@ Capybara.register_driver :chrome do |app| ) end +Capybara.register_driver :firefox do |app| + capabilities = Selenium::WebDriver::Remote::Capabilities.firefox( + log: { + level: :trace + } + ) + + options = Selenium::WebDriver::Firefox::Options.new(log_level: :trace) + + options.add_argument("--window-size=#{CAPYBARA_WINDOW_SIZE.join(',')}") + + # Run headless by default unless WEBDRIVER_HEADLESS specified + options.add_argument("--headless") unless ENV['WEBDRIVER_HEADLESS'] =~ /^(false|no|0)$/i + + Capybara::Selenium::Driver.new( + app, + browser: :firefox, + desired_capabilities: capabilities, + options: options + ) +end + Capybara.server = :puma_via_workhorse -Capybara.javascript_driver = :chrome +Capybara.javascript_driver = ENV.fetch('WEBDRIVER', :chrome).to_sym Capybara.default_max_wait_time = timeout Capybara.ignore_hidden_elements = true Capybara.default_normalize_ws = true diff --git a/spec/support/gitlab_experiment.rb b/spec/support/gitlab_experiment.rb index 45ae9958c52..bd0c88f8049 100644 --- a/spec/support/gitlab_experiment.rb +++ b/spec/support/gitlab_experiment.rb @@ -2,15 +2,31 @@ # Require the provided spec helper and matchers. require 'gitlab/experiment/rspec' +require_relative 'stub_snowplow' # This is a temporary fix until we have a larger discussion around the # challenges raised in https://gitlab.com/gitlab-org/gitlab/-/issues/300104 -class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/NamespacedClass +require Rails.root.join('app', 'experiments', 'application_experiment') +class ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass def initialize(...) super(...) Feature.persist_used!(feature_flag_name) end + + def should_track? + true + end end -# Disable all caching for experiments in tests. -Gitlab::Experiment::Configuration.cache = nil +RSpec.configure do |config| + config.include StubSnowplow, :experiment + + # Disable all caching for experiments in tests. + config.before do + allow(Gitlab::Experiment::Configuration).to receive(:cache).and_return(nil) + end + + config.before(:each, :experiment) do + stub_snowplow + end +end diff --git a/spec/support/graphql/resolver_factories.rb b/spec/support/graphql/resolver_factories.rb new file mode 100644 index 00000000000..8188f17cc43 --- /dev/null +++ b/spec/support/graphql/resolver_factories.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Graphql + module ResolverFactories + def new_resolver(resolved_value = 'Resolved value', method: :resolve) + case method + when :resolve + simple_resolver(resolved_value) + when :find_object + find_object_resolver(resolved_value) + else + raise "Cannot build a resolver for #{method}" + end + end + + private + + def simple_resolver(resolved_value = 'Resolved value') + Class.new(Resolvers::BaseResolver) do + define_method :resolve do |**_args| + resolved_value + end + end + end + + def find_object_resolver(resolved_value = 'Found object') + Class.new(Resolvers::BaseResolver) do + include ::Gitlab::Graphql::Authorize::AuthorizeResource + + def resolve(**args) + authorized_find!(**args) + end + + define_method :find_object do |**_args| + resolved_value + end + end + end + end +end diff --git a/spec/support/helpers/cycle_analytics_helpers.rb b/spec/support/helpers/cycle_analytics_helpers.rb index a90cbbf3bd3..14041ad0ac6 100644 --- a/spec/support/helpers/cycle_analytics_helpers.rb +++ b/spec/support/helpers/cycle_analytics_helpers.rb @@ -15,7 +15,7 @@ module CycleAnalyticsHelpers end def toggle_dropdown(field) - page.within("[data-testid='#{field}']") do + page.within("[data-testid*='#{field}']") do find('.dropdown-toggle').click wait_for_requests @@ -26,7 +26,7 @@ module CycleAnalyticsHelpers def select_dropdown_option_by_value(name, value, elem = '.dropdown-item') toggle_dropdown name - page.find("[data-testid='#{name}'] .dropdown-menu").find("#{elem}[value='#{value}']").click + page.find("[data-testid*='#{name}'] .dropdown-menu").find("#{elem}[value='#{value}']").click end def create_commit_referencing_issue(issue, branch_name: generate(:branch)) diff --git a/spec/support/helpers/database/database_helpers.rb b/spec/support/helpers/database/database_helpers.rb index b8d7ea3662f..db093bcef85 100644 --- a/spec/support/helpers/database/database_helpers.rb +++ b/spec/support/helpers/database/database_helpers.rb @@ -5,11 +5,65 @@ module Database # In order to directly work with views using factories, # we can swapout the view for a table of identical structure. def swapout_view_for_table(view) - ActiveRecord::Base.connection.execute(<<~SQL) + ActiveRecord::Base.connection.execute(<<~SQL.squish) CREATE TABLE #{view}_copy (LIKE #{view}); DROP VIEW #{view}; ALTER TABLE #{view}_copy RENAME TO #{view}; SQL end + + # Set statement timeout temporarily. + # Useful when testing query timeouts. + # + # Note that this method cannot restore the timeout if a query + # was canceled due to e.g. a statement timeout. + # Refrain from using this transaction in these situations. + # + # @param timeout - Statement timeout in seconds + # + # Example: + # + # with_statement_timeout(0.1) do + # model.select('pg_sleep(0.11)') + # end + def with_statement_timeout(timeout) + # Force a positive value and a minimum of 1ms for very small values. + timeout = (timeout * 1000).abs.ceil + + raise ArgumentError, 'Using a timeout of `0` means to disable statement timeout.' if timeout == 0 + + previous_timeout = ActiveRecord::Base.connection + .exec_query('SHOW statement_timeout')[0].fetch('statement_timeout') + + set_statement_timeout("#{timeout}ms") + + yield + ensure + begin + set_statement_timeout(previous_timeout) + rescue ActiveRecord::StatementInvalid + # After a transaction was canceled/aborted due to e.g. a statement + # timeout commands are ignored and will raise in PG::InFailedSqlTransaction. + # We can safely ignore this error because the statement timeout was set + # for the currrent transaction which will be closed anyway. + end + end + + # Set statement timeout for the current transaction. + # + # Note, that it does not restore the previous statement timeout. + # Use `with_statement_timeout` instead. + # + # @param timeout - Statement timeout in seconds + # + # Example: + # + # set_statement_timeout(0.1) + # model.select('pg_sleep(0.11)') + def set_statement_timeout(timeout) + ActiveRecord::Base.connection.execute( + format(%(SET LOCAL statement_timeout = '%s'), timeout) + ) + end end end diff --git a/spec/support/helpers/dependency_proxy_helpers.rb b/spec/support/helpers/dependency_proxy_helpers.rb index ebb849628bf..0d8f56906e3 100644 --- a/spec/support/helpers/dependency_proxy_helpers.rb +++ b/spec/support/helpers/dependency_proxy_helpers.rb @@ -18,11 +18,11 @@ module DependencyProxyHelpers .to_return(status: status, body: body || manifest, headers: headers) end - def stub_manifest_head(image, tag, status: 200, body: nil, digest: '123456') + def stub_manifest_head(image, tag, status: 200, body: nil, headers: {}) manifest_url = registry.manifest_url(image, tag) stub_full_request(manifest_url, method: :head) - .to_return(status: status, body: body, headers: { 'docker-content-digest' => digest } ) + .to_return(status: status, body: body, headers: headers ) end def stub_blob_download(image, blob_sha, status = 200, body = '123456') diff --git a/spec/support/helpers/design_management_test_helpers.rb b/spec/support/helpers/design_management_test_helpers.rb index db217250b17..be723a47521 100644 --- a/spec/support/helpers/design_management_test_helpers.rb +++ b/spec/support/helpers/design_management_test_helpers.rb @@ -35,7 +35,7 @@ module DesignManagementTestHelpers def act_on_designs(designs, &block) issue = designs.first.issue - version = build(:design_version, :empty, issue: issue).tap { |v| v.save!(validate: false) } + version = build(:design_version, designs_count: 0, issue: issue).tap { |v| v.save!(validate: false) } designs.each do |d| yield.create!(design: d, version: version) end diff --git a/spec/support/helpers/features/releases_helpers.rb b/spec/support/helpers/features/releases_helpers.rb index 44087f71cfa..9cce9c4882d 100644 --- a/spec/support/helpers/features/releases_helpers.rb +++ b/spec/support/helpers/features/releases_helpers.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -# These helpers fill fields on the "New Release" and -# "Edit Release" pages. They use the keyboard to navigate -# from one field to the next and assume that when -# they are called, the field to be filled out is already focused. +# These helpers fill fields on the "New Release" and "Edit Release" pages. # # Usage: # describe "..." do @@ -18,97 +15,65 @@ module Spec module Helpers module Features module ReleasesHelpers - # Returns the element that currently has keyboard focus. - # Reminder that this returns a Selenium::WebDriver::Element - # _not_ a Capybara::Node::Element - def focused_element - page.driver.browser.switch_to.active_element - end - - def fill_tag_name(tag_name, and_tab: true) - expect(focused_element).to eq(find_field('Tag name').native) + def select_new_tag_name(tag_name) + page.within '[data-testid="tag-name-field"]' do + find('button').click - focused_element.send_keys(tag_name) + wait_for_all_requests - focused_element.send_keys(:tab) if and_tab - end + find('input[aria-label="Search or create tag"]').set(tag_name) - def select_create_from(branch_name, and_tab: true) - expect(focused_element).to eq(find('[data-testid="create-from-field"] button').native) + wait_for_all_requests - focused_element.send_keys(:enter) + click_button("Create tag #{tag_name}") + end + end - # Wait for the dropdown to be rendered - page.find('.ref-selector .dropdown-menu') + def select_create_from(branch_name) + page.within '[data-testid="create-from-field"]' do + find('button').click - # Pressing Enter in the search box shouldn't submit the form - focused_element.send_keys(branch_name, :enter) + wait_for_all_requests - # Wait for the search to return - page.find('.ref-selector .dropdown-item', text: branch_name, match: :first) + find('input[aria-label="Search branches, tags, and commits"]').set(branch_name) - focused_element.send_keys(:arrow_down, :enter) + wait_for_all_requests - focused_element.send_keys(:tab) if and_tab + click_button("#{branch_name}") + end end - def fill_release_title(release_title, and_tab: true) - expect(focused_element).to eq(find_field('Release title').native) - - focused_element.send_keys(release_title) - - focused_element.send_keys(:tab) if and_tab + def fill_release_title(release_title) + fill_in('Release title', with: release_title) end - def select_milestone(milestone_title, and_tab: true) - expect(focused_element).to eq(find('[data-testid="milestones-field"] button').native) - - focused_element.send_keys(:enter) + def select_milestone(milestone_title) + page.within '[data-testid="milestones-field"]' do + find('button').click - # Wait for the dropdown to be rendered - page.find('.milestone-combobox .dropdown-menu') + wait_for_all_requests - # Clear any existing input - focused_element.attribute('value').length.times { focused_element.send_keys(:backspace) } + find('input[aria-label="Search Milestones"]').set(milestone_title) - # Pressing Enter in the search box shouldn't submit the form - focused_element.send_keys(milestone_title, :enter) + wait_for_all_requests - # Wait for the search to return - page.find('.milestone-combobox .dropdown-item', text: milestone_title, match: :first) - - focused_element.send_keys(:arrow_down, :arrow_down, :enter) - - focused_element.send_keys(:tab) if and_tab + find('button', text: milestone_title, match: :first).click + end end - def fill_release_notes(release_notes, and_tab: true) - expect(focused_element).to eq(find_field('Release notes').native) - - focused_element.send_keys(release_notes) - - # Tab past the links at the bottom of the editor - focused_element.send_keys(:tab, :tab, :tab) if and_tab + def fill_release_notes(release_notes) + fill_in('Release notes', with: release_notes) end - def fill_asset_link(link, and_tab: true) - expect(focused_element['id']).to start_with('asset-url-') - - focused_element.send_keys(link[:url], :tab, link[:title], :tab, link[:type]) - - # Tab past the "Remove asset link" button - focused_element.send_keys(:tab, :tab) if and_tab + def fill_asset_link(link) + all('input[name="asset-url"]').last.set(link[:url]) + all('input[name="asset-link-name"]').last.set(link[:title]) + all('select[name="asset-type"]').last.find("option[value=\"#{link[:type]}\"").select_option end # Click "Add another link" and tab back to the beginning of the new row def add_another_asset_link - expect(focused_element).to eq(find_button('Add another link').native) - - focused_element.send_keys(:enter, - [:shift, :tab], - [:shift, :tab], - [:shift, :tab], - [:shift, :tab]) + click_button('Add another link') end end end diff --git a/spec/support/helpers/gpg_helpers.rb b/spec/support/helpers/gpg_helpers.rb index 389e5818dbe..813c6176317 100644 --- a/spec/support/helpers/gpg_helpers.rb +++ b/spec/support/helpers/gpg_helpers.rb @@ -279,6 +279,10 @@ module GpgHelpers KEY end + def primary_keyid2 + fingerprint2[-16..-1] + end + def fingerprint2 'C447A6F6BFD9CEF8FB371785571625A930241179' end diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index 46d0c13dc18..75d9508f470 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -16,32 +16,130 @@ module GraphqlHelpers underscored_field_name.to_s.camelize(:lower) end - # Run a loader's named resolver in a way that closely mimics the framework. + def self.deep_fieldnamerize(map) + map.to_h do |k, v| + [fieldnamerize(k), v.is_a?(Hash) ? deep_fieldnamerize(v) : v] + end + end + + # Run this resolver exactly as it would be called in the framework. This + # includes all authorization hooks, all argument processing and all result + # wrapping. + # see: GraphqlHelpers#resolve_field + def resolve( + resolver_class, # [Class[<= BaseResolver]] The resolver at test. + obj: nil, # [Any] The BaseObject#object for the resolver (available as `#object` in the resolver). + args: {}, # [Hash] The arguments to the resolver (using client names). + ctx: {}, # [#to_h] The current context values. + schema: GitlabSchema, # [GraphQL::Schema] Schema to use during execution. + parent: :not_given, # A GraphQL query node to be passed as the `:parent` extra. + lookahead: :not_given # A GraphQL lookahead object to be passed as the `:lookahead` extra. + ) + # All resolution goes through fields, so we need to create one here that + # uses our resolver. Thankfully, apart from the field name, resolvers + # contain all the configuration needed to define one. + field_options = resolver_class.field_options.merge( + owner: resolver_parent, + name: 'field_value' + ) + field = ::Types::BaseField.new(**field_options) + + # All mutations accept a single `:input` argument. Wrap arguments here. + # See the unwrapping below in GraphqlHelpers#resolve_field + args = { input: args } if resolver_class <= ::Mutations::BaseMutation && !args.key?(:input) + + resolve_field(field, obj, + args: args, + ctx: ctx, + schema: schema, + object_type: resolver_parent, + extras: { parent: parent, lookahead: lookahead }) + end + + # Resolve the value of a field on an object. + # + # Use this method to test individual fields within type specs. + # + # e.g. + # + # issue = create(:issue) + # user = issue.author + # project = issue.project + # + # resolve_field(:author, issue, current_user: user, object_type: ::Types::IssueType) + # resolve_field(:issue, project, args: { iid: issue.iid }, current_user: user, object_type: ::Types::ProjectType) + # + # The `object_type` defaults to the `described_class`, so when called from type specs, + # the above can be written as: # - # First the `ready?` method is called. If it turns out that the resolver is not - # ready, then the early return is returned instead. + # # In project_type_spec.rb + # resolve_field(:author, issue, current_user: user) # - # Then the resolve method is called. - def resolve(resolver_class, args: {}, lookahead: :not_given, parent: :not_given, **resolver_args) - args = aliased_args(resolver_class, args) - args[:parent] = parent unless parent == :not_given - args[:lookahead] = lookahead unless lookahead == :not_given - resolver = resolver_instance(resolver_class, **resolver_args) - ready, early_return = sync_all { resolver.ready?(**args) } + # # In issue_type_spec.rb + # resolve_field(:issue, project, args: { iid: issue.iid }, current_user: user) + # + # NB: Arguments are passed from the client's perspective. If there is an argument + # `foo` aliased as `bar`, then we would pass `args: { bar: the_value }`, and + # types are checked before resolution. + def resolve_field( + field, # An instance of `BaseField`, or the name of a field on the current described_class + object, # The current object of the `BaseObject` this field 'belongs' to + args: {}, # Field arguments (keys will be fieldnamerized) + ctx: {}, # Context values (important ones are :current_user) + extras: {}, # Stub values for field extras (parent and lookahead) + current_user: :not_given, # The current user (specified explicitly, overrides ctx[:current_user]) + schema: GitlabSchema, # A specific schema instance + object_type: described_class # The `BaseObject` type this field belongs to + ) + field = to_base_field(field, object_type) + ctx[:current_user] = current_user unless current_user == :not_given + query = GraphQL::Query.new(schema, context: ctx.to_h) + extras[:lookahead] = negative_lookahead if extras[:lookahead] == :not_given && field.extras.include?(:lookahead) + + query_ctx = query.context + + mock_extras(query_ctx, **extras) + + parent = object_type.authorized_new(object, query_ctx) + raise UnauthorizedObject unless parent + + # TODO: This will need to change when we move to the interpreter: + # At that point, arguments will be a plain ruby hash rather than + # an Arguments object + # see: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27536 + # https://gitlab.com/gitlab-org/gitlab/-/issues/210556 + arguments = field.to_graphql.arguments_class.new( + GraphqlHelpers.deep_fieldnamerize(args), + context: query_ctx, + defaults_used: [] + ) + + # we enable the request store so we can track gitaly calls. + ::Gitlab::WithRequestStore.with_request_store do + # TODO: This will need to change when we move to the interpreter - at that + # point we will call `field#resolve` + + # Unwrap the arguments to mutations. This pairs with the wrapping in GraphqlHelpers#resolve + # If arguments are not wrapped first, then arguments processing will raise. + # If arguments are not unwrapped here, then the resolve method of the mutation will raise argument errors. + arguments = arguments.to_kwargs[:input] if field.resolver && field.resolver <= ::Mutations::BaseMutation - return early_return unless ready + field.resolve_field(parent, arguments, query_ctx) + end + end - resolver.resolve(**args) + def mock_extras(context, parent: :not_given, lookahead: :not_given) + allow(context).to receive(:parent).and_return(parent) unless parent == :not_given + allow(context).to receive(:lookahead).and_return(lookahead) unless lookahead == :not_given end - # TODO: Remove this method entirely when GraphqlHelpers uses real resolve_field - # see: https://gitlab.com/gitlab-org/gitlab/-/issues/287791 - def aliased_args(resolver, args) - definitions = resolver.arguments + # a synthetic BaseObject type to be used in resolver specs. See `GraphqlHelpers#resolve` + def resolver_parent + @resolver_parent ||= fresh_object_type('ResolverParent') + end - args.transform_keys do |k| - definitions[GraphqlHelpers.fieldnamerize(k)]&.keyword || k - end + def fresh_object_type(name = 'Object') + Class.new(::Types::BaseObject) { graphql_name name } end def resolver_instance(resolver_class, obj: nil, ctx: {}, field: nil, schema: GitlabSchema) @@ -124,9 +222,9 @@ module GraphqlHelpers lazy_vals.is_a?(Array) ? lazy_vals.map { |val| sync(val) } : sync(lazy_vals) end - def graphql_query_for(name, attributes = {}, fields = nil) + def graphql_query_for(name, args = {}, selection = nil) type = GitlabSchema.types['Query'].fields[GraphqlHelpers.fieldnamerize(name)]&.type - wrap_query(query_graphql_field(name, attributes, fields, type)) + wrap_query(query_graphql_field(name, args, selection, type)) end def wrap_query(query) @@ -171,25 +269,6 @@ module GraphqlHelpers ::Gitlab::Utils::MergeHash.merge(Array.wrap(variables).map(&:to_h)).to_json end - def resolve_field(name, object, args = {}, current_user: nil) - q = GraphQL::Query.new(GitlabSchema) - context = GraphQL::Query::Context.new(query: q, object: object, values: { current_user: current_user }) - allow(context).to receive(:parent).and_return(nil) - field = described_class.fields.fetch(GraphqlHelpers.fieldnamerize(name)) - instance = described_class.authorized_new(object, context) - raise UnauthorizedObject unless instance - - field.resolve_field(instance, args, context) - end - - def simple_resolver(resolved_value = 'Resolved value') - Class.new(Resolvers::BaseResolver) do - define_method :resolve do |**_args| - resolved_value - end - end - end - # Recursively convert a Hash with Ruby-style keys to GraphQL fieldname-style keys # # prepare_input_for_mutation({ 'my_key' => 1 }) @@ -558,24 +637,26 @@ module GraphqlHelpers end end - def execute_query(query_type) - schema = Class.new(GraphQL::Schema) do - use GraphQL::Pagination::Connections - use Gitlab::Graphql::Authorize - use Gitlab::Graphql::Pagination::Connections - - lazy_resolve ::Gitlab::Graphql::Lazy, :force - - query(query_type) - end + # assumes query_string to be let-bound in the current context + def execute_query(query_type, schema: empty_schema, graphql: query_string) + schema.query(query_type) schema.execute( - query_string, + graphql, context: { current_user: user }, variables: {} ) end + def empty_schema + Class.new(GraphQL::Schema) do + use GraphQL::Pagination::Connections + use Gitlab::Graphql::Pagination::Connections + + lazy_resolve ::Gitlab::Graphql::Lazy, :force + end + end + # A lookahead that selects everything def positive_lookahead double(selects?: true).tap do |selection| @@ -589,6 +670,23 @@ module GraphqlHelpers allow(selection).to receive(:selection).and_return(selection) end end + + private + + def to_base_field(name_or_field, object_type) + case name_or_field + when ::Types::BaseField + name_or_field + else + field_by_name(name_or_field, object_type) + end + end + + def field_by_name(name, object_type) + name = ::GraphqlHelpers.fieldnamerize(name) + + object_type.fields[name] || (raise ArgumentError, "Unknown field #{name} for #{described_class.graphql_name}") + end end # This warms our schema, doing this as part of loading the helpers to avoid diff --git a/spec/support/helpers/javascript_fixtures_helpers.rb b/spec/support/helpers/javascript_fixtures_helpers.rb index 2224af88ab9..09425c3742a 100644 --- a/spec/support/helpers/javascript_fixtures_helpers.rb +++ b/spec/support/helpers/javascript_fixtures_helpers.rb @@ -12,6 +12,7 @@ module JavaScriptFixturesHelpers included do |base| base.around do |example| # pick an arbitrary date from the past, so tests are not time dependent + # Also see spec/frontend/__helpers__/fake_date/jest.js Timecop.freeze(Time.utc(2015, 7, 3, 10)) { example.run } raise NoMethodError.new('You need to set `response` for the fixture generator! This will automatically happen with `type: :controller` or `type: :request`.', 'response') unless respond_to?(:response) diff --git a/spec/support/helpers/notification_helpers.rb b/spec/support/helpers/notification_helpers.rb index aee57b452fe..6066f4ec3bf 100644 --- a/spec/support/helpers/notification_helpers.rb +++ b/spec/support/helpers/notification_helpers.rb @@ -3,10 +3,10 @@ module NotificationHelpers extend self - def send_notifications(*new_mentions) + def send_notifications(*new_mentions, current_user: @u_disabled) mentionable.description = new_mentions.map(&:to_reference).join(' ') - notification.send(notification_method, mentionable, new_mentions, @u_disabled) + notification.send(notification_method, mentionable, new_mentions, current_user) end def create_global_setting_for(user, level) diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb index 0d0ac171baa..56177d445d6 100644 --- a/spec/support/helpers/stub_object_storage.rb +++ b/spec/support/helpers/stub_object_storage.rb @@ -114,7 +114,7 @@ module StubObjectStorage end def stub_object_storage_multipart_init(endpoint, upload_id = "upload_id") - stub_request(:post, %r{\A#{endpoint}tmp/uploads/[a-z0-9-]*\?uploads\z}) + stub_request(:post, %r{\A#{endpoint}tmp/uploads/[%A-Za-z0-9-]*\?uploads\z}) .to_return status: 200, body: <<-EOS.strip_heredoc <?xml version="1.0" encoding="UTF-8"?> <InitiateMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb index 2d71662b0eb..266c0e18ccd 100644 --- a/spec/support/helpers/test_env.rb +++ b/spec/support/helpers/test_env.rb @@ -77,7 +77,8 @@ module TestEnv 'sha-starting-with-large-number' => '8426165', 'invalid-utf8-diff-paths' => '99e4853', 'compare-with-merge-head-source' => 'f20a03d', - 'compare-with-merge-head-target' => '2f1e176' + 'compare-with-merge-head-target' => '2f1e176', + 'trailers' => 'f0a5ed6' }.freeze # gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily @@ -172,8 +173,13 @@ module TestEnv Gitlab::SetupHelper::Gitaly.create_configuration(gitaly_dir, { 'default' => repos_path }, force: true) Gitlab::SetupHelper::Gitaly.create_configuration( gitaly_dir, - { 'default' => repos_path }, force: true, - options: { gitaly_socket: "gitaly2.socket", config_filename: "gitaly2.config.toml" } + { 'default' => repos_path }, + force: true, + options: { + internal_socket_dir: File.join(gitaly_dir, "internal_gitaly2"), + gitaly_socket: "gitaly2.socket", + config_filename: "gitaly2.config.toml" + } ) Gitlab::SetupHelper::Praefect.create_configuration(gitaly_dir, { 'praefect' => repos_path }, force: true) end @@ -186,7 +192,17 @@ module TestEnv end def gitaly_dir - File.dirname(gitaly_socket_path) + socket_path = gitaly_socket_path + socket_path = File.expand_path(gitaly_socket_path) if expand_path? + + File.dirname(socket_path) + end + + # Linux fails with "bind: invalid argument" if a UNIX socket path exceeds 108 characters: + # https://github.com/golang/go/issues/6895. We use absolute paths in CI to ensure + # that changes in the current working directory don't affect GRPC reconnections. + def expand_path? + !!ENV['CI'] end def start_gitaly(gitaly_dir) diff --git a/spec/support/matchers/background_migrations_matchers.rb b/spec/support/matchers/background_migrations_matchers.rb index 0144a044f6c..08bbbcc7438 100644 --- a/spec/support/matchers/background_migrations_matchers.rb +++ b/spec/support/matchers/background_migrations_matchers.rb @@ -1,7 +1,17 @@ # frozen_string_literal: true +RSpec::Matchers.define :be_background_migration_with_arguments do |arguments| + define_method :matches? do |migration| + expect do + Gitlab::BackgroundMigration.perform(migration, arguments) + end.not_to raise_error + end +end + RSpec::Matchers.define :be_scheduled_delayed_migration do |delay, *expected| - match do |migration| + define_method :matches? do |migration| + expect(migration).to be_background_migration_with_arguments(expected) + BackgroundMigrationWorker.jobs.any? do |job| job['args'] == [migration, expected] && job['at'].to_i == (delay.to_i + Time.now.to_i) @@ -16,7 +26,9 @@ RSpec::Matchers.define :be_scheduled_delayed_migration do |delay, *expected| end RSpec::Matchers.define :be_scheduled_migration do |*expected| - match do |migration| + define_method :matches? do |migration| + expect(migration).to be_background_migration_with_arguments(expected) + BackgroundMigrationWorker.jobs.any? do |job| args = job['args'].size == 1 ? [BackgroundMigrationWorker.jobs[0]['args'][0], []] : job['args'] args == [migration, expected] @@ -29,7 +41,9 @@ RSpec::Matchers.define :be_scheduled_migration do |*expected| end RSpec::Matchers.define :be_scheduled_migration_with_multiple_args do |*expected| - match do |migration| + define_method :matches? do |migration| + expect(migration).to be_background_migration_with_arguments(expected) + BackgroundMigrationWorker.jobs.any? do |job| args = job['args'].size == 1 ? [BackgroundMigrationWorker.jobs[0]['args'][0], []] : job['args'] args[0] == migration && compare_args(args, expected) diff --git a/spec/support/matchers/email_matcher.rb b/spec/support/matchers/email_matcher.rb new file mode 100644 index 00000000000..36cf3e0e871 --- /dev/null +++ b/spec/support/matchers/email_matcher.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +RSpec::Matchers.define :have_text_part_content do |expected| + match do |actual| + @actual = actual.text_part.body.to_s + expect(@actual).to include(expected) + end + + diffable +end + +RSpec::Matchers.define :have_html_part_content do |expected| + match do |actual| + @actual = actual.html_part.body.to_s + expect(@actual).to include(expected) + end + + diffable +end diff --git a/spec/support/matchers/graphql_matchers.rb b/spec/support/matchers/graphql_matchers.rb index 8c4ba387a74..565c21e0f85 100644 --- a/spec/support/matchers/graphql_matchers.rb +++ b/spec/support/matchers/graphql_matchers.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +RSpec::Matchers.define_negated_matcher :be_nullable, :be_non_null + RSpec::Matchers.define :require_graphql_authorizations do |*expected| match do |klass| permissions = if klass.respond_to?(:required_permissions) @@ -90,7 +92,7 @@ RSpec::Matchers.define :have_graphql_arguments do |*expected| @names ||= Array.wrap(expected).map { |name| GraphqlHelpers.fieldnamerize(name) } if field.type.try(:ancestors)&.include?(GraphQL::Types::Relay::BaseConnection) - @names | %w(after before first last) + @names | %w[after before first last] else @names end @@ -103,9 +105,10 @@ RSpec::Matchers.define :have_graphql_arguments do |*expected| end failure_message do |field| - names = expected_names(field) + names = expected_names(field).inspect + args = field.arguments.keys.inspect - "expected that #{field.name} would have the following fields: #{names.inspect}, but it has #{field.arguments.keys.inspect}." + "expected that #{field.name} would have the following arguments: #{names}, but it has #{args}." end end diff --git a/spec/support/services/issues/move_and_clone_services_shared_examples.rb b/spec/support/services/issues/move_and_clone_services_shared_examples.rb new file mode 100644 index 00000000000..2b2e90c0461 --- /dev/null +++ b/spec/support/services/issues/move_and_clone_services_shared_examples.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'copy or reset relative position' do + before do + # ensure we have a relative position and it is known + old_issue.update!(relative_position: 1000) + end + + context 'when moved to a project within same group hierarchy' do + it 'does not reset the relative_position' do + expect(subject.relative_position).to eq(1000) + end + end + + context 'when moved to a project in a different group hierarchy' do + let_it_be(:new_project) { create(:project, group: create(:group)) } + + it 'does reset the relative_position' do + expect(subject.relative_position).to be_nil + end + end +end diff --git a/spec/support/services/service_response_shared_examples.rb b/spec/support/services/service_response_shared_examples.rb new file mode 100644 index 00000000000..186627347fb --- /dev/null +++ b/spec/support/services/service_response_shared_examples.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'returning an error service response' do |message: nil| + it 'returns an error service response' do + result = subject + + expect(result).to be_error + + if message + expect(result.message).to eq(message) + end + end +end + +RSpec.shared_examples 'returning a success service response' do |message: nil| + it 'returns a success service response' do + result = subject + + expect(result).to be_success + + if message + expect(result.message).to eq(message) + end + end +end diff --git a/spec/support/shared_contexts/features/error_tracking_shared_context.rb b/spec/support/shared_contexts/features/error_tracking_shared_context.rb index 1f4eb3a6df9..f04111e0ce0 100644 --- a/spec/support/shared_contexts/features/error_tracking_shared_context.rb +++ b/spec/support/shared_contexts/features/error_tracking_shared_context.rb @@ -9,7 +9,7 @@ RSpec.shared_context 'sentry error tracking context feature' do let_it_be(:issue_response) { Gitlab::Json.parse(issue_response_body) } let_it_be(:event_response_body) { fixture_file('sentry/issue_latest_event_sample_response.json') } let_it_be(:event_response) { Gitlab::Json.parse(event_response_body) } - let(:sentry_api_urls) { Sentry::ApiUrls.new(project_error_tracking_settings.api_url) } + let(:sentry_api_urls) { ErrorTracking::SentryClient::ApiUrls.new(project_error_tracking_settings.api_url) } let(:issue_id) { issue_response['id'] } let(:issue_seen) { 1.year.ago.utc } let(:formatted_issue_seen) { issue_seen.strftime("%Y-%m-%d %-l:%M:%S%p %Z") } diff --git a/spec/support/shared_contexts/issuable/merge_request_shared_context.rb b/spec/support/shared_contexts/issuable/merge_request_shared_context.rb index 0fee170a35d..debcd9a3054 100644 --- a/spec/support/shared_contexts/issuable/merge_request_shared_context.rb +++ b/spec/support/shared_contexts/issuable/merge_request_shared_context.rb @@ -1,63 +1,23 @@ # frozen_string_literal: true -RSpec.shared_context 'open merge request show action' do +RSpec.shared_context 'merge request show action' do include Spec::Support::Helpers::Features::MergeRequestHelpers - let(:user) { create(:user) } - let(:project) { create(:project, :public, :repository) } - let(:note) { create(:note_on_merge_request, project: project, noteable: open_merge_request) } - - let(:open_merge_request) do - create(:merge_request, :opened, source_project: project, author: user) - end - - before do - assign(:project, project) - assign(:merge_request, open_merge_request) - assign(:note, note) - assign(:noteable, open_merge_request) - assign(:notes, []) - assign(:pipelines, Ci::Pipeline.none) - assign(:issuable_sidebar, serialize_issuable_sidebar(user, project, open_merge_request)) - - preload_view_requirements(open_merge_request, note) - - sign_in(user) - end -end - -RSpec.shared_context 'closed merge request show action' do - include Devise::Test::ControllerHelpers - include ProjectForksHelper - include Spec::Support::Helpers::Features::MergeRequestHelpers - - let(:user) { create(:user) } - let(:project) { create(:project, :public, :repository) } - let(:forked_project) { fork_project(project, user, repository: true) } - let(:unlink_project) { Projects::UnlinkForkService.new(forked_project, user) } - let(:note) { create(:note_on_merge_request, project: project, noteable: closed_merge_request) } - - let(:closed_merge_request) do - create(:closed_merge_request, - source_project: forked_project, - target_project: project, - author: user) - end + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :public, :repository) } + let_it_be(:merge_request) { create(:merge_request, :opened, source_project: project, author: user) } + let_it_be(:note) { create(:note_on_merge_request, project: project, noteable: merge_request) } before do + allow(view).to receive(:experiment_enabled?).and_return(false) + allow(view).to receive(:current_user).and_return(user) assign(:project, project) - assign(:merge_request, closed_merge_request) - assign(:commits_count, 0) + assign(:merge_request, merge_request) assign(:note, note) - assign(:noteable, closed_merge_request) - assign(:notes, []) - assign(:pipelines, Ci::Pipeline.none) - assign(:issuable_sidebar, serialize_issuable_sidebar(user, project, closed_merge_request)) - - preload_view_requirements(closed_merge_request, note) + assign(:noteable, merge_request) + assign(:pipelines, []) + assign(:issuable_sidebar, serialize_issuable_sidebar(user, project, merge_request)) - allow(view).to receive_messages(current_user: user, - can?: true, - current_application_settings: Gitlab::CurrentSettings.current_application_settings) + preload_view_requirements(merge_request, note) end end diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb index 3fd4f2698e9..671c0cdf79c 100644 --- a/spec/support/shared_contexts/navbar_structure_context.rb +++ b/spec/support/shared_contexts/navbar_structure_context.rb @@ -5,7 +5,7 @@ RSpec.shared_context 'project navbar structure' do { nav_item: _('Analytics'), nav_sub_items: [ - _('CI / CD'), + _('CI/CD'), (_('Code Review') if Gitlab.ee?), (_('Merge Request') if Gitlab.ee?), _('Repository'), @@ -63,7 +63,7 @@ RSpec.shared_context 'project navbar structure' do nav_sub_items: [] }, { - nav_item: _('CI / CD'), + nav_item: _('CI/CD'), nav_sub_items: [ _('Pipelines'), s_('Pipelines|Editor'), @@ -111,7 +111,7 @@ RSpec.shared_context 'project navbar structure' do _('Webhooks'), _('Access Tokens'), _('Repository'), - _('CI / CD'), + _('CI/CD'), _('Operations') ].compact } @@ -124,7 +124,8 @@ RSpec.shared_context 'group navbar structure' do { nav_item: _('Analytics'), nav_sub_items: [ - _('Contribution') + _('Contribution'), + _('DevOps Adoption') ] } end @@ -137,7 +138,7 @@ RSpec.shared_context 'group navbar structure' do _('Integrations'), _('Projects'), _('Repository'), - _('CI / CD'), + _('CI/CD'), _('Packages & Registries'), _('Webhooks') ] diff --git a/spec/support/shared_contexts/policies/group_policy_shared_context.rb b/spec/support/shared_contexts/policies/group_policy_shared_context.rb index e7bc1450601..b0d7274269b 100644 --- a/spec/support/shared_contexts/policies/group_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/group_policy_shared_context.rb @@ -18,12 +18,12 @@ RSpec.shared_context 'GroupPolicy context' do ] end - let(:read_group_permissions) { %i[read_label read_list read_milestone read_board] } + let(:read_group_permissions) { %i[read_label read_issue_board_list read_milestone read_issue_board] } let(:reporter_permissions) do %i[ admin_label - admin_board + admin_issue_board read_container_image read_metrics_dashboard_annotation read_prometheus diff --git a/spec/support/shared_contexts/policies/project_policy_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_shared_context.rb index 3016494ac8d..266c8d5ee84 100644 --- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb @@ -16,8 +16,8 @@ RSpec.shared_context 'ProjectPolicy context' do let(:base_guest_permissions) do %i[ award_emoji create_issue create_merge_request_in create_note - create_project read_board read_issue read_issue_iid read_issue_link - read_label read_list read_milestone read_note read_project + create_project read_issue_board read_issue read_issue_iid read_issue_link + read_label read_issue_board_list read_milestone read_note read_project read_project_for_iids read_project_member read_release read_snippet read_wiki upload_file ] @@ -25,7 +25,7 @@ RSpec.shared_context 'ProjectPolicy context' do let(:base_reporter_permissions) do %i[ - admin_issue admin_issue_link admin_label admin_list create_snippet + admin_issue admin_issue_link admin_label admin_issue_board_list create_snippet download_code download_wiki_code fork_project metrics_dashboard read_build read_commit_status read_confidential_issues read_container_image read_deployment read_environment read_merge_request diff --git a/spec/support/shared_contexts/policies/project_policy_table_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_table_shared_context.rb index 8c9a60fa703..fbd82fbbe31 100644 --- a/spec/support/shared_contexts/policies/project_policy_table_shared_context.rb +++ b/spec/support/shared_contexts/policies/project_policy_table_shared_context.rb @@ -356,7 +356,7 @@ RSpec.shared_context 'ProjectPolicyTable context' do :private | :anonymous | 0 end - # :snippet_level, :project_level, :feature_access_level, :membership, :expected_count + # :snippet_level, :project_level, :feature_access_level, :membership, :admin_mode, :expected_count def permission_table_for_project_snippet_access :public | :public | :enabled | :admin | true | 1 :public | :public | :enabled | :admin | false | 1 diff --git a/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb b/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb index 60a29d78084..815108be447 100644 --- a/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb +++ b/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb @@ -5,8 +5,9 @@ RSpec.shared_context 'npm api setup' do include HttpBasicAuthHelpers let_it_be(:user, reload: true) { create(:user) } - let_it_be(:group) { create(:group) } - let_it_be(:project, reload: true) { create(:project, :public, namespace: group) } + let_it_be(:group) { create(:group, name: 'test-group') } + let_it_be(:namespace) { group } + let_it_be(:project, reload: true) { create(:project, :public, namespace: namespace) } let_it_be(:package, reload: true) { create(:npm_package, project: project, name: "@#{group.path}/scoped_package") } let_it_be(:token) { create(:oauth_access_token, scopes: 'api', resource_owner: user) } let_it_be(:personal_access_token) { create(:personal_access_token, user: user) } @@ -22,6 +23,10 @@ RSpec.shared_context 'set package name from package name type' do case package_name_type when :scoped_naming_convention "@#{group.path}/scoped-package" + when :scoped_no_naming_convention + '@any-scope/scoped-package' + when :unscoped + 'unscoped-package' when :non_existing 'non-existing-package' end diff --git a/spec/support/shared_contexts/security_and_compliance_permissions_shared_context.rb b/spec/support/shared_contexts/security_and_compliance_permissions_shared_context.rb new file mode 100644 index 00000000000..dc5195e4b01 --- /dev/null +++ b/spec/support/shared_contexts/security_and_compliance_permissions_shared_context.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +RSpec.shared_context '"Security & Compliance" permissions' do + let(:project_instance) { an_instance_of(Project) } + let(:user_instance) { an_instance_of(User) } + let(:before_request_defined) { false } + let(:valid_request) {} + + def self.before_request(&block) + return unless block + + let(:before_request_call) { instance_exec(&block) } + let(:before_request_defined) { true } + end + + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?).with(user_instance, :access_security_and_compliance, project_instance).and_return(true) + end + + context 'when the "Security & Compliance" feature is disabled' do + subject { response } + + before do + before_request_call if before_request_defined + + allow(Ability).to receive(:allowed?).with(user_instance, :access_security_and_compliance, project_instance).and_return(false) + valid_request + end + + it { is_expected.to have_gitlab_http_status(:not_found) } + end +end diff --git a/spec/support/shared_examples/alert_notification_service_shared_examples.rb b/spec/support/shared_examples/alert_notification_service_shared_examples.rb index 7bd6df8c608..fc935effe0e 100644 --- a/spec/support/shared_examples/alert_notification_service_shared_examples.rb +++ b/spec/support/shared_examples/alert_notification_service_shared_examples.rb @@ -27,3 +27,18 @@ RSpec.shared_examples 'Alert Notification Service sends no notifications' do |ht end end end + +RSpec.shared_examples 'creates status-change system note for an auto-resolved alert' do + it 'has 2 new system notes' do + expect { subject }.to change(Note, :count).by(2) + expect(Note.last.note).to include('Resolved') + end +end + +# Requires `source` to be defined +RSpec.shared_examples 'creates single system note based on the source of the alert' do + it 'has one new system note' do + expect { subject }.to change(Note, :count).by(1) + expect(Note.last.note).to include(source) + end +end diff --git a/spec/support/shared_examples/banzai/filters/emoji_shared_examples.rb b/spec/support/shared_examples/banzai/filters/emoji_shared_examples.rb new file mode 100644 index 00000000000..da305f5ccaa --- /dev/null +++ b/spec/support/shared_examples/banzai/filters/emoji_shared_examples.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'emoji filter' do + it 'keeps whitespace intact' do + doc = filter("This deserves a #{emoji_name}, big time.") + + expect(doc.to_html).to match(/^This deserves a <gl-emoji.+>, big time\.\z/) + end + + it 'does not match emoji in a string' do + doc = filter("'2a00:a4c0#{emoji_name}:1'") + + expect(doc.css('gl-emoji')).to be_empty + end + + it 'ignores non existent/unsupported emoji' do + exp = '<p>:foo:</p>' + doc = filter(exp) + + expect(doc.to_html).to eq(exp) + end + + it 'matches with adjacent text' do + doc = filter("#{emoji_name.delete(':')} (#{emoji_name})") + + expect(doc.css('gl-emoji').size).to eq 1 + end + + it 'does not match emoji in a pre tag' do + doc = filter("<p><pre>#{emoji_name}</pre></p>") + + expect(doc.css('img')).to be_empty + end + + it 'does not match emoji in code tag' do + doc = filter("<p><code>#{emoji_name} wow</code></p>") + + expect(doc.css('img')).to be_empty + end + + it 'does not match emoji in tt tag' do + doc = filter("<p><tt>#{emoji_name} yes!</tt></p>") + + expect(doc.css('img')).to be_empty + end +end 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 7f49d20c83e..9c8006ce4f1 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 @@ -9,6 +9,8 @@ RSpec.shared_examples 'multiple issue boards' do login_as(user) + stub_feature_flags(board_new_list: false) + visit boards_path wait_for_requests end diff --git a/spec/support/shared_examples/controllers/create_notes_rate_limit_shared_examples.rb b/spec/support/shared_examples/controllers/create_notes_rate_limit_shared_examples.rb new file mode 100644 index 00000000000..74a98c20383 --- /dev/null +++ b/spec/support/shared_examples/controllers/create_notes_rate_limit_shared_examples.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +# +# Requires a context containing: +# - user +# - params +# - request_full_path + +RSpec.shared_examples 'request exceeding rate limit' do + before do + stub_application_setting(notes_create_limit: 2) + 2.times { post :create, params: params } + end + + it 'prevents from creating more notes', :request_store do + expect { post :create, params: params } + .to change { Note.count }.by(0) + + expect(response).to have_gitlab_http_status(:too_many_requests) + expect(response.body).to eq(_('This endpoint has been requested too many times. Try again later.')) + end + + it 'logs the event in auth.log' do + attributes = { + message: 'Application_Rate_Limiter_Request', + env: :notes_create_request_limit, + remote_ip: '0.0.0.0', + request_method: 'POST', + path: request_full_path, + user_id: user.id, + username: user.username + } + + expect(Gitlab::AuthLogger).to receive(:error).with(attributes).once + post :create, params: params + end + + it 'allows user in allow-list to create notes, even if the case is different' do + user.update_attribute(:username, user.username.titleize) + stub_application_setting(notes_create_limit_allowlist: ["#{user.username.downcase}"]) + + post :create, params: params + expect(response).to have_gitlab_http_status(:found) + end +end diff --git a/spec/support/shared_examples/controllers/snippet_blob_shared_examples.rb b/spec/support/shared_examples/controllers/snippet_blob_shared_examples.rb index c3e8f807afb..62aaec85162 100644 --- a/spec/support/shared_examples/controllers/snippet_blob_shared_examples.rb +++ b/spec/support/shared_examples/controllers/snippet_blob_shared_examples.rb @@ -17,6 +17,38 @@ RSpec.shared_examples 'raw snippet blob' do end end + context 'Content Disposition' do + context 'when the disposition is inline' do + let(:inline) { true } + + it 'returns inline in the content disposition header' do + subject + + expect(response.header['Content-Disposition']).to eq('inline') + end + end + + context 'when the disposition is attachment' do + let(:inline) { false } + + it 'returns attachment plus the filename in the content disposition header' do + subject + + expect(response.header['Content-Disposition']).to match "attachment; filename=\"#{filepath}\"" + end + + context 'when the feature flag attachment_with_filename is disabled' do + it 'returns just attachment in the disposition header' do + stub_feature_flags(attachment_with_filename: false) + + subject + + expect(response.header['Content-Disposition']).to eq 'attachment' + end + end + end + end + context 'with invalid file path' do let(:filepath) { 'doesnotexist' } diff --git a/spec/support/shared_examples/features/comment_and_close_button_shared_examples.rb b/spec/support/shared_examples/features/comment_and_close_button_shared_examples.rb deleted file mode 100644 index 4ee2840ed9f..00000000000 --- a/spec/support/shared_examples/features/comment_and_close_button_shared_examples.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -RSpec.shared_examples 'page with comment and close button' do |button_text| - context 'when remove_comment_close_reopen feature flag is enabled' do - before do - stub_feature_flags(remove_comment_close_reopen: true) - setup - end - - it "does not show #{button_text} button" do - within '.note-form-actions' do - expect(page).not_to have_button(button_text) - end - end - end - - context 'when remove_comment_close_reopen feature flag is disabled' do - before do - stub_feature_flags(remove_comment_close_reopen: false) - setup - end - - it "shows #{button_text} button" do - within '.note-form-actions' do - expect(page).to have_button(button_text) - end - end - end -end diff --git a/spec/support/shared_examples/features/discussion_comments_shared_example.rb b/spec/support/shared_examples/features/discussion_comments_shared_example.rb index 6bebd59ed70..86ba2821c78 100644 --- a/spec/support/shared_examples/features/discussion_comments_shared_example.rb +++ b/spec/support/shared_examples/features/discussion_comments_shared_example.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.shared_examples 'thread comments' do |resource_name| +RSpec.shared_examples 'thread comments for commit and snippet' do |resource_name| let(:form_selector) { '.js-main-target-form' } let(:dropdown_selector) { "#{form_selector} .comment-type-dropdown" } let(:toggle_selector) { "#{dropdown_selector} .dropdown-toggle" } @@ -24,23 +24,6 @@ RSpec.shared_examples 'thread comments' do |resource_name| expect(new_comment).not_to have_selector '.discussion' end - if resource_name == 'issue' - it "clicking 'Comment & close #{resource_name}' will post a comment and close the #{resource_name}" do - find("#{form_selector} .note-textarea").send_keys(comment) - - click_button 'Comment & close issue' - - wait_for_all_requests - - expect(page).to have_content(comment) - expect(page).to have_content "@#{user.username} closed" - - new_comment = all(comments_selector).last - - expect(new_comment).not_to have_selector '.discussion' - end - end - describe 'when the toggle is clicked' do before do find("#{form_selector} .note-textarea").send_keys(comment) @@ -110,33 +93,172 @@ RSpec.shared_examples 'thread comments' do |resource_name| end it 'updates the submit button text and closes the dropdown' do - button = find(submit_selector) + expect(find(submit_selector).value).to eq 'Start thread' - # on issues page, the submit input is a <button>, on other pages it is <input> - if button.tag_name == 'button' - expect(find(submit_selector)).to have_content 'Start thread' - else - expect(find(submit_selector).value).to eq 'Start thread' + expect(page).not_to have_selector menu_selector + end + + describe 'creating a thread' do + before do + find(submit_selector).click + wait_for_requests + + find(comments_selector, match: :first) end - expect(page).not_to have_selector menu_selector + def submit_reply(text) + find("#{comments_selector} .js-vue-discussion-reply").click + find("#{comments_selector} .note-textarea").send_keys(text) + + find("#{comments_selector} .js-comment-button").click + wait_for_requests + end + + it 'clicking "Start thread" will post a thread' do + expect(page).to have_content(comment) + + new_comment = all(comments_selector).last + + expect(new_comment).to have_selector('.discussion') + end end - if resource_name =~ /(issue|merge request)/ - it 'updates the close button text' do - expect(find(close_selector)).to have_content "Start thread & close #{resource_name}" + describe 'when opening the menu' do + before do + find(toggle_selector).click + end + + it 'has "Start thread" selected' do + find("#{menu_selector} li", match: :first) + items = all("#{menu_selector} li") + + expect(items.first).to have_content 'Comment' + expect(items.first).not_to have_selector '[data-testid="check-icon"]' + expect(items.first['class']).not_to match 'droplab-item-selected' + + expect(items.last).to have_content 'Start thread' + expect(items.last).to have_selector '[data-testid="check-icon"]' + expect(items.last['class']).to match 'droplab-item-selected' end - it 'typing does not change the close button text' do - find("#{form_selector} .note-textarea").send_keys('b') + describe 'when selecting "Comment"' do + before do + find("#{menu_selector} li", match: :first).click + end + + it 'updates the submit button text and closes the dropdown' do + button = find(submit_selector) + + expect(button.value).to eq 'Comment' + + expect(page).not_to have_selector menu_selector + end - expect(find(close_selector)).to have_content "Start thread & close #{resource_name}" + it 'has "Comment" selected when opening the menu', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/196825' do + find(toggle_selector).click + + find("#{menu_selector} li", match: :first) + items = all("#{menu_selector} li") + + aggregate_failures do + expect(items.first).to have_content 'Comment' + expect(items.first).to have_selector '[data-testid="check-icon"]' + expect(items.first['class']).to match 'droplab-item-selected' + + expect(items.last).to have_content 'Start thread' + expect(items.last).not_to have_selector '[data-testid="check-icon"]' + expect(items.last['class']).not_to match 'droplab-item-selected' + end + end end end + end + end +end + +RSpec.shared_examples 'thread comments for issue, epic and merge request' do |resource_name| + let(:form_selector) { '.js-main-target-form' } + let(:dropdown_selector) { "#{form_selector} [data-testid='comment-button']" } + let(:submit_button_selector) { "#{dropdown_selector} .split-content-button" } + let(:toggle_selector) { "#{dropdown_selector} .dropdown-toggle-split" } + let(:menu_selector) { "#{dropdown_selector} .dropdown-menu" } + let(:close_selector) { "#{form_selector} .btn-comment-and-close" } + let(:comments_selector) { '.timeline > .note.timeline-entry' } + let(:comment) { 'My comment' } + + it 'clicking "Comment" will post a comment' do + expect(page).to have_selector toggle_selector + + find("#{form_selector} .note-textarea").send_keys(comment) + + find(submit_button_selector).click + + expect(page).to have_content(comment) + + new_comment = all(comments_selector).last + + expect(new_comment).not_to have_selector '.discussion' + end + + if resource_name == 'issue' + it "clicking 'Comment & close #{resource_name}' will post a comment and close the #{resource_name}" do + find("#{form_selector} .note-textarea").send_keys(comment) + + click_button 'Comment & close issue' + + wait_for_all_requests + + expect(page).to have_content(comment) + expect(page).to have_content "@#{user.username} closed" + + new_comment = all(comments_selector).last + + expect(new_comment).not_to have_selector '.discussion' + end + end + + describe 'when the toggle is clicked' do + before do + find("#{form_selector} .note-textarea").send_keys(comment) + + find(toggle_selector).click + end + + it 'has a "Comment" item (selected by default) and "Start thread" item' do + expect(page).to have_selector menu_selector + + find("#{menu_selector} li", match: :first) + items = all("#{menu_selector} li") + + expect(page).to have_selector("#{dropdown_selector}[data-track-label='comment_button']") + + expect(items.first).to have_content 'Comment' + expect(items.first).to have_content "Add a general comment to this #{resource_name}." + + expect(items.last).to have_content 'Start thread' + expect(items.last).to have_content "Discuss a specific suggestion or question#{' that needs to be resolved' if resource_name == 'merge request'}." + end + + it 'closes the menu when clicking the toggle or body' do + find(toggle_selector).click + + expect(page).not_to have_selector menu_selector + + find(toggle_selector).click + find("#{form_selector} .note-textarea").click + + expect(page).not_to have_selector menu_selector + end + + describe 'when selecting "Start thread"' do + before do + find("#{menu_selector} li", match: :first) + all("#{menu_selector} li").last.click + end describe 'creating a thread' do before do - find(submit_selector).click + find(submit_button_selector).click wait_for_requests find(comments_selector, match: :first) @@ -146,6 +268,7 @@ RSpec.shared_examples 'thread comments' do |resource_name| find("#{comments_selector} .js-vue-discussion-reply").click find("#{comments_selector} .note-textarea").send_keys(text) + # .js-comment-button here refers to the reply button in note_form.vue find("#{comments_selector} .js-comment-button").click wait_for_requests end @@ -228,13 +351,11 @@ RSpec.shared_examples 'thread comments' do |resource_name| find("#{menu_selector} li", match: :first) items = all("#{menu_selector} li") + expect(page).to have_selector("#{dropdown_selector}[data-track-label='start_thread_button']") + expect(items.first).to have_content 'Comment' - expect(items.first).not_to have_selector '[data-testid="check-icon"]' - expect(items.first['class']).not_to match 'droplab-item-selected' expect(items.last).to have_content 'Start thread' - expect(items.last).to have_selector '[data-testid="check-icon"]' - expect(items.last['class']).to match 'droplab-item-selected' end describe 'when selecting "Comment"' do @@ -243,14 +364,9 @@ RSpec.shared_examples 'thread comments' do |resource_name| end it 'updates the submit button text and closes the dropdown' do - button = find(submit_selector) + button = find(submit_button_selector) - # on issues page, the submit input is a <button>, on other pages it is <input> - if button.tag_name == 'button' - expect(button).to have_content 'Comment' - else - expect(button.value).to eq 'Comment' - end + expect(button).to have_content 'Comment' expect(page).not_to have_selector menu_selector end @@ -267,21 +383,17 @@ RSpec.shared_examples 'thread comments' do |resource_name| end end - it 'has "Comment" selected when opening the menu', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/196825' do + it 'has "Comment" selected when opening the menu' do find(toggle_selector).click find("#{menu_selector} li", match: :first) items = all("#{menu_selector} li") - aggregate_failures do - expect(items.first).to have_content 'Comment' - expect(items.first).to have_selector '[data-testid="check-icon"]' - expect(items.first['class']).to match 'droplab-item-selected' + expect(page).to have_selector("#{dropdown_selector}[data-track-label='comment_button']") - expect(items.last).to have_content 'Start thread' - expect(items.last).not_to have_selector '[data-testid="check-icon"]' - expect(items.last['class']).not_to match 'droplab-item-selected' - end + expect(items.first).to have_content 'Comment' + + expect(items.last).to have_content 'Start thread' end end end diff --git a/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb b/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb index 3fec1a56c0c..7a32f61d4fa 100644 --- a/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb +++ b/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb @@ -1,11 +1,7 @@ # frozen_string_literal: true RSpec.shared_examples 'issuable invite members experiments' do - context 'when invite_members_version_a experiment is enabled' do - before do - stub_experiment_for_subject(invite_members_version_a: true) - end - + context 'when a privileged user can invite' do it 'shows a link for inviting members and follows through to the members page' do project.add_maintainer(user) visit issuable_path @@ -51,9 +47,9 @@ RSpec.shared_examples 'issuable invite members experiments' do end end - context 'when no invite members experiments are enabled' do + context 'when invite_members_version_b experiment is disabled' do it 'shows author in assignee dropdown and no invite link' do - project.add_maintainer(user) + project.add_developer(user) visit issuable_path find('.block.assignee .edit-link').click diff --git a/spec/support/shared_examples/features/project_upload_files_shared_examples.rb b/spec/support/shared_examples/features/project_upload_files_shared_examples.rb index 25203fa3182..00d3bd08218 100644 --- a/spec/support/shared_examples/features/project_upload_files_shared_examples.rb +++ b/spec/support/shared_examples/features/project_upload_files_shared_examples.rb @@ -3,7 +3,13 @@ RSpec.shared_examples 'it uploads and commit a new text file' do it 'uploads and commit a new text file', :js do find('.add-to-tree').click - click_link('Upload file') + + page.within('.dropdown-menu') do + click_link('Upload file') + + wait_for_requests + end + drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt')) page.within('#modal-upload-blob') do @@ -29,7 +35,13 @@ end RSpec.shared_examples 'it uploads and commit a new image file' do it 'uploads and commit a new image file', :js do find('.add-to-tree').click - click_link('Upload file') + + page.within('.dropdown-menu') do + click_link('Upload file') + + wait_for_requests + end + drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'logo_sample.svg')) page.within('#modal-upload-blob') do @@ -82,3 +94,21 @@ RSpec.shared_examples 'it uploads and commit a new file to a forked project' do expect(page).to have_content('Sed ut perspiciatis unde omnis') end end + +RSpec.shared_examples 'uploads and commits a new text file via "upload file" button' do + it 'uploads and commits a new text file via "upload file" button', :js do + find('[data-testid="upload-file-button"]').click + + attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true) + + page.within('#details-modal-upload-blob') do + fill_in(:commit_message, with: 'New commit message') + end + + click_button('Upload file') + + expect(page).to have_content('New commit message') + expect(page).to have_content('Lorem ipsum dolor sit amet') + expect(page).to have_content('Sed ut perspiciatis unde omnis') + end +end diff --git a/spec/support/shared_examples/features/variable_list_shared_examples.rb b/spec/support/shared_examples/features/variable_list_shared_examples.rb index e0d169c6868..2fd88b610e9 100644 --- a/spec/support/shared_examples/features/variable_list_shared_examples.rb +++ b/spec/support/shared_examples/features/variable_list_shared_examples.rb @@ -2,7 +2,7 @@ RSpec.shared_examples 'variable list' do it 'shows a list of variables' do - page.within('.ci-variable-table') do + page.within('[data-testid="ci-variable-table"]') do expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq(variable.key) end end @@ -16,7 +16,7 @@ RSpec.shared_examples 'variable list' do wait_for_requests - page.within('.ci-variable-table') do + page.within('[data-testid="ci-variable-table"]') do expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq('key') end end @@ -30,7 +30,7 @@ RSpec.shared_examples 'variable list' do wait_for_requests - page.within('.ci-variable-table') do + page.within('[data-testid="ci-variable-table"]') do expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq('key') expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Protected"] svg[data-testid="mobile-issue-close-icon"]')).to be_present end @@ -45,14 +45,14 @@ RSpec.shared_examples 'variable list' do wait_for_requests - page.within('.ci-variable-table') do + page.within('[data-testid="ci-variable-table"]') do expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq('key') expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Masked"] svg[data-testid="close-icon"]')).to be_present end end it 'reveals and hides variables' do - page.within('.ci-variable-table') do + page.within('[data-testid="ci-variable-table"]') do expect(first('.js-ci-variable-row td[data-label="Key"]').text).to eq(variable.key) expect(page).to have_content('*' * 17) @@ -72,7 +72,7 @@ RSpec.shared_examples 'variable list' do it 'deletes a variable' do expect(page).to have_selector('.js-ci-variable-row', count: 1) - page.within('.ci-variable-table') do + page.within('[data-testid="ci-variable-table"]') do click_button('Edit') end @@ -86,7 +86,7 @@ RSpec.shared_examples 'variable list' do end it 'edits a variable' do - page.within('.ci-variable-table') do + page.within('[data-testid="ci-variable-table"]') do click_button('Edit') end @@ -102,7 +102,7 @@ RSpec.shared_examples 'variable list' do end it 'edits a variable to be unmasked' do - page.within('.ci-variable-table') do + page.within('[data-testid="ci-variable-table"]') do click_button('Edit') end @@ -115,13 +115,13 @@ RSpec.shared_examples 'variable list' do wait_for_requests - page.within('.ci-variable-table') do + page.within('[data-testid="ci-variable-table"]') do expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Masked"] svg[data-testid="close-icon"]')).to be_present end end it 'edits a variable to be masked' do - page.within('.ci-variable-table') do + page.within('[data-testid="ci-variable-table"]') do click_button('Edit') end @@ -133,7 +133,7 @@ RSpec.shared_examples 'variable list' do wait_for_requests - page.within('.ci-variable-table') do + page.within('[data-testid="ci-variable-table"]') do click_button('Edit') end @@ -143,7 +143,7 @@ RSpec.shared_examples 'variable list' do click_button('Update variable') end - page.within('.ci-variable-table') do + page.within('[data-testid="ci-variable-table"]') do expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Masked"] svg[data-testid="mobile-issue-close-icon"]')).to be_present end end @@ -211,7 +211,7 @@ RSpec.shared_examples 'variable list' do expect(page).to have_selector('.js-ci-variable-row', count: 3) # Remove the `akey` variable - page.within('.ci-variable-table') do + page.within('[data-testid="ci-variable-table"]') do page.within('.js-ci-variable-row:first-child') do click_button('Edit') end diff --git a/spec/support/shared_examples/graphql/mutation_shared_examples.rb b/spec/support/shared_examples/graphql/mutation_shared_examples.rb index 84ebd4852b9..51d52cbb901 100644 --- a/spec/support/shared_examples/graphql/mutation_shared_examples.rb +++ b/spec/support/shared_examples/graphql/mutation_shared_examples.rb @@ -48,6 +48,6 @@ RSpec.shared_examples 'a mutation that returns errors in the response' do |error it do post_graphql_mutation(mutation, current_user: current_user) - expect(mutation_response['errors']).to eq(errors) + expect(mutation_response['errors']).to match_array(errors) end end diff --git a/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb index 2b93d174653..2e3a3ce6b41 100644 --- a/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb +++ b/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb @@ -66,9 +66,7 @@ RSpec.shared_examples 'boards create mutation' do context 'when the Boards::CreateService returns an error response' do before do - allow_next_instance_of(Boards::CreateService) do |service| - allow(service).to receive(:execute).and_return(ServiceResponse.error(message: 'There was an error.')) - end + params[:name] = '' end it 'does not create a board' do @@ -80,7 +78,7 @@ RSpec.shared_examples 'boards create mutation' do expect(mutation_response).to have_key('board') expect(mutation_response['board']).to be_nil - expect(mutation_response['errors'].first).to eq('There was an error.') + expect(mutation_response['errors'].first).to eq('There was an error when creating a board.') end end end diff --git a/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb b/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb index d294f034d2e..bb4270d7db6 100644 --- a/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb +++ b/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb @@ -21,14 +21,14 @@ RSpec.shared_examples 'a mutation which can mutate a spammable' do end end - describe "#with_spam_action_fields" do + describe "#with_spam_action_response_fields" do it 'resolves with spam action fields' do subject # NOTE: We do not need to assert on the specific values of spam action fields here, we only need - # to verify that #with_spam_action_fields was invoked and that the fields are present in the - # response. The specific behavior of #with_spam_action_fields is covered in the - # CanMutateSpammable unit tests. + # to verify that #with_spam_action_response_fields was invoked and that the fields are present in the + # response. The specific behavior of #with_spam_action_response_fields is covered in the + # HasSpamActionResponseFields unit tests. expect(mutation_response.keys) .to include('spam', 'spamLogId', 'needsCaptchaResponse', 'captchaSiteKey') end diff --git a/spec/support/shared_examples/graphql/notes_on_noteables_shared_examples.rb b/spec/support/shared_examples/graphql/notes_on_noteables_shared_examples.rb index 41b7da07d2d..0d2e9f6ec8c 100644 --- a/spec/support/shared_examples/graphql/notes_on_noteables_shared_examples.rb +++ b/spec/support/shared_examples/graphql/notes_on_noteables_shared_examples.rb @@ -17,7 +17,7 @@ RSpec.shared_context 'exposing regular notes on a noteable in GraphQL' do notes { edges { node { - #{all_graphql_fields_for('Note')} + #{all_graphql_fields_for('Note', max_depth: 1)} } } } 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 269e9170906..bc091a678e2 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 @@ -6,7 +6,7 @@ RSpec.shared_examples 'Gitlab-style deprecations' do expect { subject(deprecation_reason: 'foo') }.to raise_error( ArgumentError, 'Use `deprecated` property instead of `deprecation_reason`. ' \ - 'See https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-fields-and-enum-values' + 'See https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-fields-arguments-and-enum-values' ) end diff --git a/spec/support/shared_examples/helpers/issuable_description_templates_shared_examples.rb b/spec/support/shared_examples/helpers/issuable_description_templates_shared_examples.rb index 9e8c96d576a..47e34b21036 100644 --- a/spec/support/shared_examples/helpers/issuable_description_templates_shared_examples.rb +++ b/spec/support/shared_examples/helpers/issuable_description_templates_shared_examples.rb @@ -23,11 +23,11 @@ RSpec.shared_examples 'project issuable templates' do end it 'returns only md files as issue templates' do - expect(helper.issuable_templates(project, 'issue')).to eq(templates('issue', project)) + expect(helper.issuable_templates(project, 'issue')).to eq(expected_templates('issue')) end it 'returns only md files as merge_request templates' do - expect(helper.issuable_templates(project, 'merge_request')).to eq(templates('merge_request', project)) + expect(helper.issuable_templates(project, 'merge_request')).to eq(expected_templates('merge_request')) end end diff --git a/spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb index 145a7290ac8..7d341d79bae 100644 --- a/spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb @@ -8,6 +8,7 @@ RSpec.shared_examples_for 'value stream analytics event' do it { expect(described_class.identifier).to be_a_kind_of(Symbol) } it { expect(instance.object_type.ancestors).to include(ApplicationRecord) } it { expect(instance).to respond_to(:timestamp_projection) } + it { expect(instance).to respond_to(:markdown_description) } it { expect(instance.column_list).to be_a_kind_of(Array) } describe '#apply_query_customization' do diff --git a/spec/support/shared_examples/lib/gitlab/usage_data_counters/issue_activity_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb index edd9b6cdf37..aa6e64a3820 100644 --- a/spec/support/shared_examples/lib/gitlab/usage_data_counters/issue_activity_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.shared_examples 'a tracked issue edit event' do |event| +RSpec.shared_examples 'a daily tracked issuable event' do before do stub_application_setting(usage_ping_enabled: true) end @@ -25,3 +25,13 @@ RSpec.shared_examples 'a tracked issue edit event' do |event| expect(track_action(author: nil)).to be_nil end end + +RSpec.shared_examples 'does not track when feature flag is disabled' do |feature_flag| + context "when feature flag #{feature_flag} is disabled" do + it 'does not track action' do + stub_feature_flags(feature_flag => false) + + expect(track_action(author: user1)).to be_nil + end + end +end diff --git a/spec/support/shared_examples/lib/sentry/client_shared_examples.rb b/spec/support/shared_examples/lib/sentry/client_shared_examples.rb index 4221708b55c..d73c7b6848d 100644 --- a/spec/support/shared_examples/lib/sentry/client_shared_examples.rb +++ b/spec/support/shared_examples/lib/sentry/client_shared_examples.rb @@ -26,7 +26,7 @@ RSpec.shared_examples 'no Sentry redirects' do |http_method| end it 'does not follow redirects' do - expect { subject }.to raise_exception(Sentry::Client::Error, 'Sentry response status code: 302') + expect { subject }.to raise_exception(ErrorTracking::SentryClient::Error, 'Sentry response status code: 302') expect(redirect_req_stub).to have_been_requested expect(redirected_req_stub).not_to have_been_requested end @@ -53,7 +53,7 @@ RSpec.shared_examples 'maps Sentry exceptions' do |http_method| it do expect { subject } - .to raise_exception(Sentry::Client::Error, message) + .to raise_exception(ErrorTracking::SentryClient::Error, message) end end end diff --git a/spec/support/shared_examples/metrics/active_record_subscriber_shared_examples.rb b/spec/support/shared_examples/metrics/active_record_subscriber_shared_examples.rb new file mode 100644 index 00000000000..7bf2456c548 --- /dev/null +++ b/spec/support/shared_examples/metrics/active_record_subscriber_shared_examples.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'store ActiveRecord info in RequestStore' do |db_role| + it 'prevents db counters from leaking to the next transaction' do + 2.times do + Gitlab::WithRequestStore.with_request_store do + subscriber.sql(event) + + if db_role == :primary + expect(described_class.db_counter_payload).to eq( + db_count: record_query ? 1 : 0, + db_write_count: record_write_query ? 1 : 0, + db_cached_count: record_cached_query ? 1 : 0, + db_primary_cached_count: record_cached_query ? 1 : 0, + db_primary_count: record_query ? 1 : 0, + db_primary_duration_s: record_query ? 0.002 : 0, + db_replica_cached_count: 0, + db_replica_count: 0, + db_replica_duration_s: 0.0 + ) + elsif db_role == :replica + expect(described_class.db_counter_payload).to eq( + db_count: record_query ? 1 : 0, + db_write_count: record_write_query ? 1 : 0, + db_cached_count: record_cached_query ? 1 : 0, + db_primary_cached_count: 0, + db_primary_count: 0, + db_primary_duration_s: 0.0, + db_replica_cached_count: record_cached_query ? 1 : 0, + db_replica_count: record_query ? 1 : 0, + db_replica_duration_s: record_query ? 0.002 : 0 + ) + else + expect(described_class.db_counter_payload).to eq( + db_count: record_query ? 1 : 0, + db_write_count: record_write_query ? 1 : 0, + db_cached_count: record_cached_query ? 1 : 0 + ) + end + end + end + end +end + +RSpec.shared_examples 'record ActiveRecord metrics in a metrics transaction' do |db_role| + it 'increments only db counters' do + if record_query + expect(transaction).to receive(:increment).with(:gitlab_transaction_db_count_total, 1) + expect(transaction).to receive(:increment).with("gitlab_transaction_db_#{db_role}_count_total".to_sym, 1) if db_role + else + expect(transaction).not_to receive(:increment).with(:gitlab_transaction_db_count_total, 1) + expect(transaction).not_to receive(:increment).with("gitlab_transaction_db_#{db_role}_count_total".to_sym, 1) if db_role + end + + if record_write_query + expect(transaction).to receive(:increment).with(:gitlab_transaction_db_write_count_total, 1) + else + expect(transaction).not_to receive(:increment).with(:gitlab_transaction_db_write_count_total, 1) + end + + if record_cached_query + expect(transaction).to receive(:increment).with(:gitlab_transaction_db_cached_count_total, 1) + expect(transaction).to receive(:increment).with("gitlab_transaction_db_#{db_role}_cached_count_total".to_sym, 1) if db_role + else + expect(transaction).not_to receive(:increment).with(:gitlab_transaction_db_cached_count_total, 1) + expect(transaction).not_to receive(:increment).with("gitlab_transaction_db_#{db_role}_cached_count_total".to_sym, 1) if db_role + end + + subscriber.sql(event) + end + + it 'observes sql_duration metric' do + if record_query + expect(transaction).to receive(:observe).with(:gitlab_sql_duration_seconds, 0.002) + expect(transaction).to receive(:observe).with("gitlab_sql_#{db_role}_duration_seconds".to_sym, 0.002) if db_role + else + expect(transaction).not_to receive(:observe) + end + + subscriber.sql(event) + end +end + +RSpec.shared_examples 'record ActiveRecord metrics' do |db_role| + context 'when both web and background transaction are available' do + let(:transaction) { double('Gitlab::Metrics::WebTransaction') } + let(:background_transaction) { double('Gitlab::Metrics::WebTransaction') } + + before do + allow(::Gitlab::Metrics::WebTransaction).to receive(:current) + .and_return(transaction) + allow(::Gitlab::Metrics::BackgroundTransaction).to receive(:current) + .and_return(background_transaction) + allow(transaction).to receive(:increment) + allow(transaction).to receive(:observe) + end + + it_behaves_like 'record ActiveRecord metrics in a metrics transaction', db_role + + it 'captures the metrics for web only' do + expect(background_transaction).not_to receive(:observe) + expect(background_transaction).not_to receive(:increment) + + subscriber.sql(event) + end + end + + context 'when web transaction is available' do + let(:transaction) { double('Gitlab::Metrics::WebTransaction') } + + before do + allow(::Gitlab::Metrics::WebTransaction).to receive(:current) + .and_return(transaction) + allow(::Gitlab::Metrics::BackgroundTransaction).to receive(:current) + .and_return(nil) + allow(transaction).to receive(:increment) + allow(transaction).to receive(:observe) + end + + it_behaves_like 'record ActiveRecord metrics in a metrics transaction', db_role + end + + context 'when background transaction is available' do + let(:transaction) { double('Gitlab::Metrics::BackgroundTransaction') } + + before do + allow(::Gitlab::Metrics::WebTransaction).to receive(:current) + .and_return(nil) + allow(::Gitlab::Metrics::BackgroundTransaction).to receive(:current) + .and_return(transaction) + allow(transaction).to receive(:increment) + allow(transaction).to receive(:observe) + end + + it_behaves_like 'record ActiveRecord metrics in a metrics transaction', db_role + 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 92fd4363134..60a02d85a1e 100644 --- a/spec/support/shared_examples/models/application_setting_shared_examples.rb +++ b/spec/support/shared_examples/models/application_setting_shared_examples.rb @@ -289,6 +289,7 @@ RSpec.shared_examples 'application settings examples' do describe '#pick_repository_storage' do before do + allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(%w(default backup)) allow(setting).to receive(:repository_storages_weighted).and_return({ 'default' => 20, 'backup' => 80 }) end @@ -304,15 +305,19 @@ RSpec.shared_examples 'application settings examples' do describe '#normalized_repository_storage_weights' do using RSpec::Parameterized::TableSyntax - where(:storages, :normalized) do - { 'default' => 0, 'backup' => 100 } | { 'default' => 0.0, 'backup' => 1.0 } - { 'default' => 100, 'backup' => 100 } | { 'default' => 0.5, 'backup' => 0.5 } - { 'default' => 20, 'backup' => 80 } | { 'default' => 0.2, 'backup' => 0.8 } - { 'default' => 0, 'backup' => 0 } | { 'default' => 0.0, 'backup' => 0.0 } + where(:config_storages, :storages, :normalized) do + %w(default backup) | { 'default' => 0, 'backup' => 100 } | { 'default' => 0.0, 'backup' => 1.0 } + %w(default backup) | { 'default' => 100, 'backup' => 100 } | { 'default' => 0.5, 'backup' => 0.5 } + %w(default backup) | { 'default' => 20, 'backup' => 80 } | { 'default' => 0.2, 'backup' => 0.8 } + %w(default backup) | { 'default' => 0, 'backup' => 0 } | { 'default' => 0.0, 'backup' => 0.0 } + %w(default) | { 'default' => 0, 'backup' => 100 } | { 'default' => 0.0 } + %w(default) | { 'default' => 100, 'backup' => 100 } | { 'default' => 1.0 } + %w(default) | { 'default' => 20, 'backup' => 80 } | { 'default' => 1.0 } end with_them do before do + allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(config_storages) allow(setting).to receive(:repository_storages_weighted).and_return(storages) end diff --git a/spec/support/shared_examples/models/boards/user_preferences_shared_examples.rb b/spec/support/shared_examples/models/boards/user_preferences_shared_examples.rb new file mode 100644 index 00000000000..766aeac9476 --- /dev/null +++ b/spec/support/shared_examples/models/boards/user_preferences_shared_examples.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'list_preferences_for user' do |list_factory, list_id_attribute| + subject { create(list_factory) } # rubocop:disable Rails/SaveBang + + let_it_be(:user) { create(:user) } + + describe '#preferences_for' do + context 'when user is nil' do + it 'returns not persisted preferences' do + preferences = subject.preferences_for(nil) + + expect(preferences).not_to be_persisted + expect(preferences[list_id_attribute]).to eq(subject.id) + expect(preferences.user_id).to be_nil + end + end + + context 'when a user preference already exists' do + before do + subject.update_preferences_for(user, collapsed: true) + end + + it 'loads preference for user' do + preferences = subject.preferences_for(user) + + expect(preferences).to be_persisted + expect(preferences.collapsed).to eq(true) + end + end + + context 'when preferences for user does not exist' do + it 'returns not persisted preferences' do + preferences = subject.preferences_for(user) + + expect(preferences).not_to be_persisted + expect(preferences.user_id).to eq(user.id) + expect(preferences.public_send(list_id_attribute)).to eq(subject.id) + end + end + end + + describe '#update_preferences_for' do + context 'when user is present' do + context 'when there are no preferences for user' do + it 'creates new user preferences' do + expect { subject.update_preferences_for(user, collapsed: true) }.to change { subject.preferences.count }.by(1) + expect(subject.preferences_for(user).collapsed).to eq(true) + end + end + + context 'when there are preferences for user' do + it 'updates user preferences' do + subject.update_preferences_for(user, collapsed: false) + + expect { subject.update_preferences_for(user, collapsed: true) }.not_to change { subject.preferences.count } + expect(subject.preferences_for(user).collapsed).to eq(true) + end + end + + context 'when user is nil' do + it 'does not create user preferences' do + expect { subject.update_preferences_for(nil, collapsed: true) }.not_to change { subject.preferences.count } + end + end + end + end +end diff --git a/spec/support/shared_examples/models/chat_service_shared_examples.rb b/spec/support/shared_examples/models/chat_service_shared_examples.rb index ad237ad9f49..59e249bb865 100644 --- a/spec/support/shared_examples/models/chat_service_shared_examples.rb +++ b/spec/support/shared_examples/models/chat_service_shared_examples.rb @@ -53,9 +53,13 @@ RSpec.shared_examples "chat service" do |service_name| end it "calls #{service_name} API" do - subject.execute(sample_data) + result = subject.execute(sample_data) - expect(WebMock).to have_requested(:post, webhook_url).with { |req| req.body =~ /\A{"#{content_key}":.+}\Z/ }.once + expect(result).to be(true) + expect(WebMock).to have_requested(:post, webhook_url).once.with { |req| + json_body = Gitlab::Json.parse(req.body).with_indifferent_access + expect(json_body).to include(payload) + } end end @@ -67,7 +71,8 @@ RSpec.shared_examples "chat service" do |service_name| it "does not call #{service_name} API" do result = subject.execute(sample_data) - expect(result).to be_falsy + expect(result).to be(false) + expect(WebMock).not_to have_requested(:post, webhook_url) end end diff --git a/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb b/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb index f91e4bd8cf7..68142e667a4 100644 --- a/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb @@ -18,7 +18,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type| context 'with a project' do it_behaves_like 'AtomicInternalId' do let(:internal_id_attribute) { :iid } - let(:instance) { build(timebox_type, *timebox_args, project: build(:project), group: nil) } + let(:instance) { build(timebox_type, *timebox_args, project: create(:project), group: nil) } let(:scope) { :project } let(:scope_attrs) { { project: instance.project } } let(:usage) { timebox_table_name } @@ -28,7 +28,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type| context 'with a group' do it_behaves_like 'AtomicInternalId' do let(:internal_id_attribute) { :iid } - let(:instance) { build(timebox_type, *timebox_args, project: nil, group: build(:group)) } + let(:instance) { build(timebox_type, *timebox_args, project: nil, group: create(:group)) } let(:scope) { :group } let(:scope_attrs) { { namespace: instance.group } } let(:usage) { timebox_table_name } diff --git a/spec/support/shared_examples/models/email_format_shared_examples.rb b/spec/support/shared_examples/models/email_format_shared_examples.rb index a8115e440a4..77ded168637 100644 --- a/spec/support/shared_examples/models/email_format_shared_examples.rb +++ b/spec/support/shared_examples/models/email_format_shared_examples.rb @@ -6,7 +6,7 @@ # Note: You have access to `email_value` which is the email address value # being currently tested). -RSpec.shared_examples 'an object with email-formated attributes' do |*attributes| +RSpec.shared_examples 'an object with email-formatted attributes' do |*attributes| attributes.each do |attribute| describe "specifically its :#{attribute} attribute" do %w[ @@ -45,7 +45,7 @@ RSpec.shared_examples 'an object with email-formated attributes' do |*attributes end end -RSpec.shared_examples 'an object with RFC3696 compliant email-formated attributes' do |*attributes| +RSpec.shared_examples 'an object with RFC3696 compliant email-formatted attributes' do |*attributes| attributes.each do |attribute| describe "specifically its :#{attribute} attribute" do %w[ diff --git a/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb b/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb index a1867e1ce39..71a76121d38 100644 --- a/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb +++ b/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb @@ -7,7 +7,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| let(:webhook_url) { 'https://example.gitlab.com' } def execute_with_options(options) - receive(:new).with(webhook_url, options.merge(http_client: SlackService::Notifier::HTTPClient)) + receive(:new).with(webhook_url, options.merge(http_client: SlackMattermost::Notifier::HTTPClient)) .and_return(double(:slack_service).as_null_object) end @@ -66,193 +66,180 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| end describe "#execute" do - let(:user) { create(:user) } - let(:project) { create(:project, :repository, :wiki_repo) } - let(:username) { 'slack_username' } - let(:channel) { 'slack_channel' } - let(:issue_service_options) { { title: 'Awesome issue', description: 'please fix' } } + let_it_be(:project) { create(:project, :repository, :wiki_repo) } + let_it_be(:user) { create(:user) } - let(:data) do - Gitlab::DataBuilder::Push.build_sample(project, user) - end + let(:chat_service) { described_class.new( { project: project, webhook: webhook_url, branches_to_be_notified: 'all' }.merge(chat_service_params)) } + let(:chat_service_params) { {} } + let(:data) { Gitlab::DataBuilder::Push.build_sample(project, user) } let!(:stubbed_resolved_hostname) do stub_full_request(webhook_url, method: :post).request_pattern.uri_pattern.to_s end - before do - allow(chat_service).to receive_messages( - project: project, - project_id: project.id, - service_hook: true, - webhook: webhook_url - ) + subject(:execute_service) { chat_service.execute(data) } - issue_service = Issues::CreateService.new(project, user, issue_service_options) - @issue = issue_service.execute - @issues_sample_data = issue_service.hook_data(@issue, 'open') - - project.add_developer(user) - opts = { - title: 'Awesome merge_request', - description: 'please fix', - source_branch: 'feature', - target_branch: 'master' - } - merge_service = MergeRequests::CreateService.new(project, - user, opts) - @merge_request = merge_service.execute - @merge_sample_data = merge_service.hook_data(@merge_request, - 'open') - - opts = { - title: "Awesome wiki_page", - content: "Some text describing some thing or another", - format: "md", - message: "user created page: Awesome wiki_page" - } - - @wiki_page = create(:wiki_page, wiki: project.wiki, **opts) - @wiki_page_sample_data = Gitlab::DataBuilder::WikiPage.build(@wiki_page, user, 'create') - end - - it "calls #{service_name} API for push events" do - chat_service.execute(data) - - expect(WebMock).to have_requested(:post, stubbed_resolved_hostname).once - end + shared_examples 'calls the service API with the event message' do |event_message| + specify do + expect_next_instance_of(Slack::Messenger) do |messenger| + expect(messenger).to receive(:ping).with(event_message, anything).and_call_original + end - it "calls #{service_name} API for issue events" do - chat_service.execute(@issues_sample_data) + execute_service - expect(WebMock).to have_requested(:post, stubbed_resolved_hostname).once + expect(WebMock).to have_requested(:post, stubbed_resolved_hostname).once + end end - it "calls #{service_name} API for merge requests events" do - chat_service.execute(@merge_sample_data) + context 'with username for slack configured' do + let(:chat_service_params) { { username: 'slack_username' } } + + it 'uses the username as an option' do + expect(Slack::Messenger).to execute_with_options(username: 'slack_username') - expect(WebMock).to have_requested(:post, stubbed_resolved_hostname).once + execute_service + end end - it "calls #{service_name} API for wiki page events" do - chat_service.execute(@wiki_page_sample_data) + context 'push events' do + let(:data) { Gitlab::DataBuilder::Push.build_sample(project, user) } - expect(WebMock).to have_requested(:post, stubbed_resolved_hostname).once - end + it_behaves_like 'calls the service API with the event message', /pushed to branch/ - it "calls #{service_name} API for deployment events" do - deployment_event_data = { object_kind: 'deployment' } + context 'with event channel' do + let(:chat_service_params) { { push_channel: 'random' } } - chat_service.execute(deployment_event_data) + it 'uses the right channel for push event' do + expect(Slack::Messenger).to execute_with_options(channel: ['random']) - expect(WebMock).to have_requested(:post, stubbed_resolved_hostname).once + execute_service + end + end end - it 'uses the username as an option for slack when configured' do - allow(chat_service).to receive(:username).and_return(username) - - expect(Slack::Messenger).to execute_with_options(username: username) + context 'tag_push events' do + let(:oldrev) { Gitlab::Git::BLANK_SHA } + let(:newrev) { '8a2a6eb295bb170b34c24c76c49ed0e9b2eaf34b' } # gitlab-test: git rev-parse refs/tags/v1.1.0 + let(:ref) { 'refs/tags/v1.1.0' } + let(:data) { Git::TagHooksService.new(project, user, change: { oldrev: oldrev, newrev: newrev, ref: ref }).send(:push_data) } - chat_service.execute(data) + it_behaves_like 'calls the service API with the event message', /pushed new tag/ end - it 'uses the channel as an option when it is configured' do - allow(chat_service).to receive(:channel).and_return(channel) - expect(Slack::Messenger).to execute_with_options(channel: [channel]) - chat_service.execute(data) - end + context 'issue events' do + let_it_be(:issue) { create(:issue) } + let(:data) { issue.to_hook_data(user) } - context "event channels" do - it "uses the right channel for push event" do - chat_service.update!(push_channel: "random") + it_behaves_like 'calls the service API with the event message', /Issue (.*?) opened by/ - expect(Slack::Messenger).to execute_with_options(channel: ['random']) + context 'whith event channel' do + let(:chat_service_params) { { issue_channel: 'random' } } - chat_service.execute(data) - end + it 'uses the right channel for issue event' do + expect(Slack::Messenger).to execute_with_options(channel: ['random']) - it "uses the right channel for merge request event" do - chat_service.update!(merge_request_channel: "random") + execute_service + end - expect(Slack::Messenger).to execute_with_options(channel: ['random']) + context 'for confidential issues' do + before_all do + issue.update!(confidential: true) + end - chat_service.execute(@merge_sample_data) - end + it 'falls back to issue channel' do + expect(Slack::Messenger).to execute_with_options(channel: ['random']) + + execute_service + end - it "uses the right channel for issue event" do - chat_service.update!(issue_channel: "random") + context 'and confidential_issue_channel is defined' do + let(:chat_service_params) { { issue_channel: 'random', confidential_issue_channel: 'confidential' } } - expect(Slack::Messenger).to execute_with_options(channel: ['random']) + it 'uses the confidential issue channel when it is defined' do + expect(Slack::Messenger).to execute_with_options(channel: ['confidential']) - chat_service.execute(@issues_sample_data) + execute_service + end + end + end end + end + + context 'merge request events' do + let_it_be(:merge_request) { create(:merge_request) } + let(:data) { merge_request.to_hook_data(user) } - context 'for confidential issues' do - let(:issue_service_options) { { title: 'Secret', confidential: true } } + it_behaves_like 'calls the service API with the event message', /opened merge request/ - it "uses confidential issue channel" do - chat_service.update!(confidential_issue_channel: 'confidential') + context 'with event channel' do + let(:chat_service_params) { { merge_request_channel: 'random' } } - expect(Slack::Messenger).to execute_with_options(channel: ['confidential']) + it 'uses the right channel for merge request event' do + expect(Slack::Messenger).to execute_with_options(channel: ['random']) - chat_service.execute(@issues_sample_data) + execute_service end + end + end + + context 'wiki page events' do + let_it_be(:wiki_page) { create(:wiki_page, wiki: project.wiki, message: 'user created page: Awesome wiki_page') } + let(:data) { Gitlab::DataBuilder::WikiPage.build(wiki_page, user, 'create') } - it 'falls back to issue channel' do - chat_service.update!(issue_channel: 'fallback_channel') + it_behaves_like 'calls the service API with the event message', / created (.*?)wikis\/(.*?)|wiki page> in/ - expect(Slack::Messenger).to execute_with_options(channel: ['fallback_channel']) + context 'with event channel' do + let(:chat_service_params) { { wiki_page_channel: 'random' } } - chat_service.execute(@issues_sample_data) + it 'uses the right channel for wiki event' do + expect(Slack::Messenger).to execute_with_options(channel: ['random']) + + execute_service end end + end - it "uses the right channel for wiki event" do - chat_service.update!(wiki_page_channel: "random") - - expect(Slack::Messenger).to execute_with_options(channel: ['random']) + context 'deployment events' do + let_it_be(:deployment) { create(:deployment) } + let(:data) { Gitlab::DataBuilder::Deployment.build(deployment) } - chat_service.execute(@wiki_page_sample_data) - end + it_behaves_like 'calls the service API with the event message', /Deploy to (.*?) created/ + end - context "note event" do - let(:issue_note) do - create(:note_on_issue, project: project, note: "issue note") - end + context 'note event' do + let_it_be(:issue_note) { create(:note_on_issue, project: project, note: "issue note") } + let(:data) { Gitlab::DataBuilder::Note.build(issue_note, user) } - it "uses the right channel" do - chat_service.update!(note_channel: "random") + it_behaves_like 'calls the service API with the event message', /commented on issue/ - note_data = Gitlab::DataBuilder::Note.build(issue_note, user) + context 'with event channel' do + let(:chat_service_params) { { note_channel: 'random' } } + it 'uses the right channel' do expect(Slack::Messenger).to execute_with_options(channel: ['random']) - chat_service.execute(note_data) + execute_service end context 'for confidential notes' do - before do - issue_note.noteable.update!(confidential: true) + before_all do + issue_note.update!(confidential: true) end - it "uses confidential channel" do - chat_service.update!(confidential_note_channel: "confidential") - - note_data = Gitlab::DataBuilder::Note.build(issue_note, user) - - expect(Slack::Messenger).to execute_with_options(channel: ['confidential']) + it 'falls back to note channel' do + expect(Slack::Messenger).to execute_with_options(channel: ['random']) - chat_service.execute(note_data) + execute_service end - it 'falls back to note channel' do - chat_service.update!(note_channel: "fallback_channel") - - note_data = Gitlab::DataBuilder::Note.build(issue_note, user) + context 'and confidential_note_channel is defined' do + let(:chat_service_params) { { note_channel: 'random', confidential_note_channel: 'confidential' } } - expect(Slack::Messenger).to execute_with_options(channel: ['fallback_channel']) + it 'uses confidential channel' do + expect(Slack::Messenger).to execute_with_options(channel: ['confidential']) - chat_service.execute(note_data) + execute_service + end end end end diff --git a/spec/support/shared_examples/models/wiki_shared_examples.rb b/spec/support/shared_examples/models/wiki_shared_examples.rb index 89d30688b5c..abc6e3ecce8 100644 --- a/spec/support/shared_examples/models/wiki_shared_examples.rb +++ b/spec/support/shared_examples/models/wiki_shared_examples.rb @@ -354,27 +354,47 @@ RSpec.shared_examples 'wiki model' do subject.repository.create_file(user, 'image.png', image, branch_name: subject.default_branch, message: 'add image') end - it 'returns the latest version of the file if it exists' do - file = subject.find_file('image.png') + shared_examples 'find_file results' do + it 'returns the latest version of the file if it exists' do + file = subject.find_file('image.png') - expect(file.mime_type).to eq('image/png') - end + expect(file.mime_type).to eq('image/png') + end + + it 'returns nil if the page does not exist' do + expect(subject.find_file('non-existent')).to eq(nil) + end + + it 'returns a Gitlab::Git::WikiFile instance' do + file = subject.find_file('image.png') + + expect(file).to be_a Gitlab::Git::WikiFile + end - it 'returns nil if the page does not exist' do - expect(subject.find_file('non-existent')).to eq(nil) + it 'returns the whole file' do + file = subject.find_file('image.png') + image.rewind + + expect(file.raw_data.b).to eq(image.read.b) + end end - it 'returns a Gitlab::Git::WikiFile instance' do - file = subject.find_file('image.png') + it_behaves_like 'find_file results' + + context 'when load_content is disabled' do + it 'includes the file data in the Gitlab::Git::WikiFile' do + file = subject.find_file('image.png', load_content: false) - expect(file).to be_a Gitlab::Git::WikiFile + expect(file.raw_data).to be_empty + end end - it 'returns the whole file' do - file = subject.find_file('image.png') - image.rewind + context 'when feature flag :gitaly_find_file is disabled' do + before do + stub_feature_flags(gitaly_find_file: false) + end - expect(file.raw_data.b).to eq(image.read.b) + it_behaves_like 'find_file results' end end diff --git a/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb b/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb index 17fd2b836d3..92849ddf1cb 100644 --- a/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb +++ b/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb @@ -93,6 +93,6 @@ end def submit_time(quick_action) fill_in 'note[note]', with: quick_action - find('.js-comment-submit-button').click + find('[data-testid="comment-button"]').click wait_for_requests end diff --git a/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb index 49b6fc13900..54ea876bed2 100644 --- a/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb @@ -1,63 +1,13 @@ # frozen_string_literal: true RSpec.shared_examples 'conan ping endpoint' do - it 'responds with 401 Unauthorized when no token provided' do + it 'responds with 200 OK when no token provided' do get api(url) - expect(response).to have_gitlab_http_status(:unauthorized) - end - - it 'responds with 200 OK when valid token is provided' do - jwt = build_jwt(personal_access_token) - get api(url), headers: build_token_auth_header(jwt.encoded) - - expect(response).to have_gitlab_http_status(:ok) - expect(response.headers['X-Conan-Server-Capabilities']).to eq("") - end - - it 'responds with 200 OK when valid job token is provided' do - jwt = build_jwt_from_job(job) - get api(url), headers: build_token_auth_header(jwt.encoded) - expect(response).to have_gitlab_http_status(:ok) expect(response.headers['X-Conan-Server-Capabilities']).to eq("") end - it 'responds with 200 OK when valid deploy token is provided' do - jwt = build_jwt_from_deploy_token(deploy_token) - get api(url), headers: build_token_auth_header(jwt.encoded) - - expect(response).to have_gitlab_http_status(:ok) - expect(response.headers['X-Conan-Server-Capabilities']).to eq("") - end - - it 'responds with 401 Unauthorized when invalid access token ID is provided' do - jwt = build_jwt(double(id: 12345), user_id: personal_access_token.user_id) - get api(url), headers: build_token_auth_header(jwt.encoded) - - expect(response).to have_gitlab_http_status(:unauthorized) - end - - it 'responds with 401 Unauthorized when invalid user is provided' do - jwt = build_jwt(personal_access_token, user_id: 12345) - get api(url), headers: build_token_auth_header(jwt.encoded) - - expect(response).to have_gitlab_http_status(:unauthorized) - end - - it 'responds with 401 Unauthorized when the provided JWT is signed with different secret' do - jwt = build_jwt(personal_access_token, secret: SecureRandom.base64(32)) - get api(url), headers: build_token_auth_header(jwt.encoded) - - expect(response).to have_gitlab_http_status(:unauthorized) - end - - it 'responds with 401 Unauthorized when invalid JWT is provided' do - get api(url), headers: build_token_auth_header('invalid-jwt') - - expect(response).to have_gitlab_http_status(:unauthorized) - end - context 'packages feature disabled' do it 'responds with 404 Not Found' do stub_packages_setting(enabled: false) @@ -72,7 +22,10 @@ RSpec.shared_examples 'conan search endpoint' do before do project.update_column(:visibility_level, Gitlab::VisibilityLevel::PUBLIC) - get api(url), headers: headers, params: params + # Do not pass the HTTP_AUTHORIZATION header, + # in order to test that this public project's packages + # are visible to anonymous search. + get api(url), params: params end subject { json_response['results'] } @@ -109,6 +62,33 @@ RSpec.shared_examples 'conan authenticate endpoint' do end end + it 'responds with 401 Unauthorized when an invalid access token ID is provided' do + jwt = build_jwt(double(id: 12345), user_id: personal_access_token.user_id) + get api(url), headers: build_token_auth_header(jwt.encoded) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'responds with 401 Unauthorized when invalid user is provided' do + jwt = build_jwt(personal_access_token, user_id: 12345) + get api(url), headers: build_token_auth_header(jwt.encoded) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'responds with 401 Unauthorized when the provided JWT is signed with different secret' do + jwt = build_jwt(personal_access_token, secret: SecureRandom.base64(32)) + get api(url), headers: build_token_auth_header(jwt.encoded) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'responds with 401 UnauthorizedOK when invalid JWT is provided' do + get api(url), headers: build_token_auth_header('invalid-jwt') + + expect(response).to have_gitlab_http_status(:unauthorized) + end + context 'when valid JWT access token is provided' do it 'responds with 200' do subject @@ -507,19 +487,37 @@ RSpec.shared_examples 'delete package endpoint' do end end +RSpec.shared_examples 'allows download with no token' do + context 'with no private token' do + let(:headers) { {} } + + it 'returns 200' do + subject + + expect(response).to have_gitlab_http_status(:ok) + end + end +end + RSpec.shared_examples 'denies download with no token' do context 'with no private token' do let(:headers) { {} } - it 'returns 400' do + it 'returns 404' do subject - expect(response).to have_gitlab_http_status(:unauthorized) + expect(response).to have_gitlab_http_status(:not_found) end end end RSpec.shared_examples 'a public project with packages' do + before do + project.update_column(:visibility_level, Gitlab::VisibilityLevel::PUBLIC) + end + + it_behaves_like 'allows download with no token' + it 'returns the file' do subject diff --git a/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb index 54f4ba7ff73..274516cd87b 100644 --- a/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb @@ -25,7 +25,7 @@ RSpec.shared_examples 'group and project boards query' do board = create(:board, resource_parent: board_parent, name: 'A') allow(Ability).to receive(:allowed?).and_call_original - allow(Ability).to receive(:allowed?).with(user, :read_board, board).and_return(false) + allow(Ability).to receive(:allowed?).with(user, :read_issue_board, board).and_return(false) post_graphql(query, current_user: current_user) diff --git a/spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb new file mode 100644 index 00000000000..66fbfa798b0 --- /dev/null +++ b/spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'group and project packages query' do + include GraphqlHelpers + + context 'when user has access to the resource' do + before do + resource.add_reporter(current_user) + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query' + + it 'returns packages successfully' do + expect(package_names).to contain_exactly( + package.name, + maven_package.name, + debian_package.name, + composer_package.name + ) + end + + it 'deals with metadata' do + expect(target_shas).to contain_exactly(composer_metadatum.target_sha) + end + end + + context 'when the user does not have access to the resource' do + before do + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query' + + it 'returns nil' do + expect(packages).to be_nil + end + end + + context 'when the user is not authenticated' do + before do + post_graphql(query) + end + + it_behaves_like 'a working graphql query' + + it 'returns nil' do + expect(packages).to be_nil + end + end +end diff --git a/spec/support/shared_examples/requests/api/logging_application_context_shared_examples.rb b/spec/support/shared_examples/requests/api/logging_application_context_shared_examples.rb index 038ede884c8..4a71b696d57 100644 --- a/spec/support/shared_examples/requests/api/logging_application_context_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/logging_application_context_shared_examples.rb @@ -22,3 +22,19 @@ RSpec.shared_examples 'storing arguments in the application context' do hash.transform_keys! { |key| "meta.#{key}" } end end + +RSpec.shared_examples 'not executing any extra queries for the application context' do |expected_extra_queries = 0| + it 'does not execute more queries than without adding anything to the application context' do + # Call the subject once to memoize all factories being used for the spec, so they won't + # add any queries to the expectation. + subject_proc.call + + expect do + allow(Gitlab::ApplicationContext).to receive(:push).and_call_original + subject_proc.call + end.to issue_same_number_of_queries_as { + allow(Gitlab::ApplicationContext).to receive(:push) + subject_proc.call + }.with_threshold(expected_extra_queries).ignoring_cached_queries + end +end diff --git a/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb index be051dcbb7b..c15c59e1a1d 100644 --- a/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb @@ -45,136 +45,234 @@ RSpec.shared_examples 'handling get metadata requests' do |scope: :project| end end - where(:auth, :package_name_type, :request_forward, :visibility, :user_role, :expected_result, :expected_status) do - nil | :scoped_naming_convention | true | 'PUBLIC' | nil | :accept | :ok - nil | :scoped_naming_convention | false | 'PUBLIC' | nil | :accept | :ok - nil | :non_existing | true | 'PUBLIC' | nil | :redirect | :redirected - nil | :non_existing | false | 'PUBLIC' | nil | :reject | :not_found - nil | :scoped_naming_convention | true | 'PRIVATE' | nil | :reject | :not_found - nil | :scoped_naming_convention | false | 'PRIVATE' | nil | :reject | :not_found - nil | :non_existing | true | 'PRIVATE' | nil | :redirect | :redirected - nil | :non_existing | false | 'PRIVATE' | nil | :reject | :not_found - nil | :scoped_naming_convention | true | 'INTERNAL' | nil | :reject | :not_found - nil | :scoped_naming_convention | false | 'INTERNAL' | nil | :reject | :not_found - nil | :non_existing | true | 'INTERNAL' | nil | :redirect | :redirected - nil | :non_existing | false | 'INTERNAL' | nil | :reject | :not_found - - :oauth | :scoped_naming_convention | true | 'PUBLIC' | :guest | :accept | :ok - :oauth | :scoped_naming_convention | true | 'PUBLIC' | :reporter | :accept | :ok - :oauth | :scoped_naming_convention | false | 'PUBLIC' | :guest | :accept | :ok - :oauth | :scoped_naming_convention | false | 'PUBLIC' | :reporter | :accept | :ok - :oauth | :non_existing | true | 'PUBLIC' | :guest | :redirect | :redirected - :oauth | :non_existing | true | 'PUBLIC' | :reporter | :redirect | :redirected - :oauth | :non_existing | false | 'PUBLIC' | :guest | :reject | :not_found - :oauth | :non_existing | false | 'PUBLIC' | :reporter | :reject | :not_found - :oauth | :scoped_naming_convention | true | 'PRIVATE' | :guest | :reject | :forbidden - :oauth | :scoped_naming_convention | true | 'PRIVATE' | :reporter | :accept | :ok - :oauth | :scoped_naming_convention | false | 'PRIVATE' | :guest | :reject | :forbidden - :oauth | :scoped_naming_convention | false | 'PRIVATE' | :reporter | :accept | :ok - :oauth | :non_existing | true | 'PRIVATE' | :guest | :redirect | :redirected - :oauth | :non_existing | true | 'PRIVATE' | :reporter | :redirect | :redirected - :oauth | :non_existing | false | 'PRIVATE' | :guest | :reject | :forbidden - :oauth | :non_existing | false | 'PRIVATE' | :reporter | :reject | :not_found - :oauth | :scoped_naming_convention | true | 'INTERNAL' | :guest | :accept | :ok - :oauth | :scoped_naming_convention | true | 'INTERNAL' | :reporter | :accept | :ok - :oauth | :scoped_naming_convention | false | 'INTERNAL' | :guest | :accept | :ok - :oauth | :scoped_naming_convention | false | 'INTERNAL' | :reporter | :accept | :ok - :oauth | :non_existing | true | 'INTERNAL' | :guest | :redirect | :redirected - :oauth | :non_existing | true | 'INTERNAL' | :reporter | :redirect | :redirected - :oauth | :non_existing | false | 'INTERNAL' | :guest | :reject | :not_found - :oauth | :non_existing | false | 'INTERNAL' | :reporter | :reject | :not_found - - :personal_access_token | :scoped_naming_convention | true | 'PUBLIC' | :guest | :accept | :ok - :personal_access_token | :scoped_naming_convention | true | 'PUBLIC' | :reporter | :accept | :ok - :personal_access_token | :scoped_naming_convention | false | 'PUBLIC' | :guest | :accept | :ok - :personal_access_token | :scoped_naming_convention | false | 'PUBLIC' | :reporter | :accept | :ok - :personal_access_token | :non_existing | true | 'PUBLIC' | :guest | :redirect | :redirected - :personal_access_token | :non_existing | true | 'PUBLIC' | :reporter | :redirect | :redirected - :personal_access_token | :non_existing | false | 'PUBLIC' | :guest | :reject | :not_found - :personal_access_token | :non_existing | false | 'PUBLIC' | :reporter | :reject | :not_found - :personal_access_token | :scoped_naming_convention | true | 'PRIVATE' | :guest | :reject | :forbidden - :personal_access_token | :scoped_naming_convention | true | 'PRIVATE' | :reporter | :accept | :ok - :personal_access_token | :scoped_naming_convention | false | 'PRIVATE' | :guest | :reject | :forbidden - :personal_access_token | :scoped_naming_convention | false | 'PRIVATE' | :reporter | :accept | :ok - :personal_access_token | :non_existing | true | 'PRIVATE' | :guest | :redirect | :redirected - :personal_access_token | :non_existing | true | 'PRIVATE' | :reporter | :redirect | :redirected - :personal_access_token | :non_existing | false | 'PRIVATE' | :guest | :reject | :forbidden - :personal_access_token | :non_existing | false | 'PRIVATE' | :reporter | :reject | :not_found - :personal_access_token | :scoped_naming_convention | true | 'INTERNAL' | :guest | :accept | :ok - :personal_access_token | :scoped_naming_convention | true | 'INTERNAL' | :reporter | :accept | :ok - :personal_access_token | :scoped_naming_convention | false | 'INTERNAL' | :guest | :accept | :ok - :personal_access_token | :scoped_naming_convention | false | 'INTERNAL' | :reporter | :accept | :ok - :personal_access_token | :non_existing | true | 'INTERNAL' | :guest | :redirect | :redirected - :personal_access_token | :non_existing | true | 'INTERNAL' | :reporter | :redirect | :redirected - :personal_access_token | :non_existing | false | 'INTERNAL' | :guest | :reject | :not_found - :personal_access_token | :non_existing | false | 'INTERNAL' | :reporter | :reject | :not_found - - :job_token | :scoped_naming_convention | true | 'PUBLIC' | :developer | :accept | :ok - :job_token | :scoped_naming_convention | false | 'PUBLIC' | :developer | :accept | :ok - :job_token | :non_existing | true | 'PUBLIC' | :developer | :redirect | :redirected - :job_token | :non_existing | false | 'PUBLIC' | :developer | :reject | :not_found - :job_token | :scoped_naming_convention | true | 'PRIVATE' | :developer | :accept | :ok - :job_token | :scoped_naming_convention | false | 'PRIVATE' | :developer | :accept | :ok - :job_token | :non_existing | true | 'PRIVATE' | :developer | :redirect | :redirected - :job_token | :non_existing | false | 'PRIVATE' | :developer | :reject | :not_found - :job_token | :scoped_naming_convention | true | 'INTERNAL' | :developer | :accept | :ok - :job_token | :scoped_naming_convention | false | 'INTERNAL' | :developer | :accept | :ok - :job_token | :non_existing | true | 'INTERNAL' | :developer | :redirect | :redirected - :job_token | :non_existing | false | 'INTERNAL' | :developer | :reject | :not_found - - :deploy_token | :scoped_naming_convention | true | 'PUBLIC' | nil | :accept | :ok - :deploy_token | :scoped_naming_convention | false | 'PUBLIC' | nil | :accept | :ok - :deploy_token | :non_existing | true | 'PUBLIC' | nil | :redirect | :redirected - :deploy_token | :non_existing | false | 'PUBLIC' | nil | :reject | :not_found - :deploy_token | :scoped_naming_convention | true | 'PRIVATE' | nil | :accept | :ok - :deploy_token | :scoped_naming_convention | false | 'PRIVATE' | nil | :accept | :ok - :deploy_token | :non_existing | true | 'PRIVATE' | nil | :redirect | :redirected - :deploy_token | :non_existing | false | 'PRIVATE' | nil | :reject | :not_found - :deploy_token | :scoped_naming_convention | true | 'INTERNAL' | nil | :accept | :ok - :deploy_token | :scoped_naming_convention | false | 'INTERNAL' | nil | :accept | :ok - :deploy_token | :non_existing | true | 'INTERNAL' | nil | :redirect | :redirected - :deploy_token | :non_existing | false | 'INTERNAL' | nil | :reject | :not_found - end + shared_examples 'handling all conditions' do + where(:auth, :package_name_type, :request_forward, :visibility, :user_role, :expected_result, :expected_status) do + nil | :scoped_naming_convention | true | :public | nil | :accept | :ok + nil | :scoped_naming_convention | false | :public | nil | :accept | :ok + nil | :scoped_no_naming_convention | true | :public | nil | :accept | :ok + nil | :scoped_no_naming_convention | false | :public | nil | :accept | :ok + nil | :unscoped | true | :public | nil | :accept | :ok + nil | :unscoped | false | :public | nil | :accept | :ok + nil | :non_existing | true | :public | nil | :redirect | :redirected + nil | :non_existing | false | :public | nil | :reject | :not_found + nil | :scoped_naming_convention | true | :private | nil | :reject | :not_found + nil | :scoped_naming_convention | false | :private | nil | :reject | :not_found + nil | :scoped_no_naming_convention | true | :private | nil | :reject | :not_found + nil | :scoped_no_naming_convention | false | :private | nil | :reject | :not_found + nil | :unscoped | true | :private | nil | :reject | :not_found + nil | :unscoped | false | :private | nil | :reject | :not_found + nil | :non_existing | true | :private | nil | :redirect | :redirected + nil | :non_existing | false | :private | nil | :reject | :not_found + nil | :scoped_naming_convention | true | :internal | nil | :reject | :not_found + nil | :scoped_naming_convention | false | :internal | nil | :reject | :not_found + nil | :scoped_no_naming_convention | true | :internal | nil | :reject | :not_found + nil | :scoped_no_naming_convention | false | :internal | nil | :reject | :not_found + nil | :unscoped | true | :internal | nil | :reject | :not_found + nil | :unscoped | false | :internal | nil | :reject | :not_found + nil | :non_existing | true | :internal | nil | :redirect | :redirected + nil | :non_existing | false | :internal | nil | :reject | :not_found + + :oauth | :scoped_naming_convention | true | :public | :guest | :accept | :ok + :oauth | :scoped_naming_convention | true | :public | :reporter | :accept | :ok + :oauth | :scoped_naming_convention | false | :public | :guest | :accept | :ok + :oauth | :scoped_naming_convention | false | :public | :reporter | :accept | :ok + :oauth | :scoped_no_naming_convention | true | :public | :guest | :accept | :ok + :oauth | :scoped_no_naming_convention | true | :public | :reporter | :accept | :ok + :oauth | :scoped_no_naming_convention | false | :public | :guest | :accept | :ok + :oauth | :scoped_no_naming_convention | false | :public | :reporter | :accept | :ok + :oauth | :unscoped | true | :public | :guest | :accept | :ok + :oauth | :unscoped | true | :public | :reporter | :accept | :ok + :oauth | :unscoped | false | :public | :guest | :accept | :ok + :oauth | :unscoped | false | :public | :reporter | :accept | :ok + :oauth | :non_existing | true | :public | :guest | :redirect | :redirected + :oauth | :non_existing | true | :public | :reporter | :redirect | :redirected + :oauth | :non_existing | false | :public | :guest | :reject | :not_found + :oauth | :non_existing | false | :public | :reporter | :reject | :not_found + :oauth | :scoped_naming_convention | true | :private | :guest | :reject | :forbidden + :oauth | :scoped_naming_convention | true | :private | :reporter | :accept | :ok + :oauth | :scoped_naming_convention | false | :private | :guest | :reject | :forbidden + :oauth | :scoped_naming_convention | false | :private | :reporter | :accept | :ok + :oauth | :scoped_no_naming_convention | true | :private | :guest | :reject | :forbidden + :oauth | :scoped_no_naming_convention | true | :private | :reporter | :accept | :ok + :oauth | :scoped_no_naming_convention | false | :private | :guest | :reject | :forbidden + :oauth | :scoped_no_naming_convention | false | :private | :reporter | :accept | :ok + :oauth | :unscoped | true | :private | :guest | :reject | :forbidden + :oauth | :unscoped | true | :private | :reporter | :accept | :ok + :oauth | :unscoped | false | :private | :guest | :reject | :forbidden + :oauth | :unscoped | false | :private | :reporter | :accept | :ok + :oauth | :non_existing | true | :private | :guest | :redirect | :redirected + :oauth | :non_existing | true | :private | :reporter | :redirect | :redirected + :oauth | :non_existing | false | :private | :guest | :reject | :forbidden + :oauth | :non_existing | false | :private | :reporter | :reject | :not_found + :oauth | :scoped_naming_convention | true | :internal | :guest | :accept | :ok + :oauth | :scoped_naming_convention | true | :internal | :reporter | :accept | :ok + :oauth | :scoped_naming_convention | false | :internal | :guest | :accept | :ok + :oauth | :scoped_naming_convention | false | :internal | :reporter | :accept | :ok + :oauth | :scoped_no_naming_convention | true | :internal | :guest | :accept | :ok + :oauth | :scoped_no_naming_convention | true | :internal | :reporter | :accept | :ok + :oauth | :scoped_no_naming_convention | false | :internal | :guest | :accept | :ok + :oauth | :scoped_no_naming_convention | false | :internal | :reporter | :accept | :ok + :oauth | :unscoped | true | :internal | :guest | :accept | :ok + :oauth | :unscoped | true | :internal | :reporter | :accept | :ok + :oauth | :unscoped | false | :internal | :guest | :accept | :ok + :oauth | :unscoped | false | :internal | :reporter | :accept | :ok + :oauth | :non_existing | true | :internal | :guest | :redirect | :redirected + :oauth | :non_existing | true | :internal | :reporter | :redirect | :redirected + :oauth | :non_existing | false | :internal | :guest | :reject | :not_found + :oauth | :non_existing | false | :internal | :reporter | :reject | :not_found + + :personal_access_token | :scoped_naming_convention | true | :public | :guest | :accept | :ok + :personal_access_token | :scoped_naming_convention | true | :public | :reporter | :accept | :ok + :personal_access_token | :scoped_naming_convention | false | :public | :guest | :accept | :ok + :personal_access_token | :scoped_naming_convention | false | :public | :reporter | :accept | :ok + :personal_access_token | :scoped_no_naming_convention | true | :public | :guest | :accept | :ok + :personal_access_token | :scoped_no_naming_convention | true | :public | :reporter | :accept | :ok + :personal_access_token | :scoped_no_naming_convention | false | :public | :guest | :accept | :ok + :personal_access_token | :scoped_no_naming_convention | false | :public | :reporter | :accept | :ok + :personal_access_token | :unscoped | true | :public | :guest | :accept | :ok + :personal_access_token | :unscoped | true | :public | :reporter | :accept | :ok + :personal_access_token | :unscoped | false | :public | :guest | :accept | :ok + :personal_access_token | :unscoped | false | :public | :reporter | :accept | :ok + :personal_access_token | :non_existing | true | :public | :guest | :redirect | :redirected + :personal_access_token | :non_existing | true | :public | :reporter | :redirect | :redirected + :personal_access_token | :non_existing | false | :public | :guest | :reject | :not_found + :personal_access_token | :non_existing | false | :public | :reporter | :reject | :not_found + :personal_access_token | :scoped_naming_convention | true | :private | :guest | :reject | :forbidden + :personal_access_token | :scoped_naming_convention | true | :private | :reporter | :accept | :ok + :personal_access_token | :scoped_naming_convention | false | :private | :guest | :reject | :forbidden + :personal_access_token | :scoped_naming_convention | false | :private | :reporter | :accept | :ok + :personal_access_token | :scoped_no_naming_convention | true | :private | :guest | :reject | :forbidden + :personal_access_token | :scoped_no_naming_convention | true | :private | :reporter | :accept | :ok + :personal_access_token | :scoped_no_naming_convention | false | :private | :guest | :reject | :forbidden + :personal_access_token | :scoped_no_naming_convention | false | :private | :reporter | :accept | :ok + :personal_access_token | :unscoped | true | :private | :guest | :reject | :forbidden + :personal_access_token | :unscoped | true | :private | :reporter | :accept | :ok + :personal_access_token | :unscoped | false | :private | :guest | :reject | :forbidden + :personal_access_token | :unscoped | false | :private | :reporter | :accept | :ok + :personal_access_token | :non_existing | true | :private | :guest | :redirect | :redirected + :personal_access_token | :non_existing | true | :private | :reporter | :redirect | :redirected + :personal_access_token | :non_existing | false | :private | :guest | :reject | :forbidden + :personal_access_token | :non_existing | false | :private | :reporter | :reject | :not_found + :personal_access_token | :scoped_naming_convention | true | :internal | :guest | :accept | :ok + :personal_access_token | :scoped_naming_convention | true | :internal | :reporter | :accept | :ok + :personal_access_token | :scoped_naming_convention | false | :internal | :guest | :accept | :ok + :personal_access_token | :scoped_naming_convention | false | :internal | :reporter | :accept | :ok + :personal_access_token | :scoped_no_naming_convention | true | :internal | :guest | :accept | :ok + :personal_access_token | :scoped_no_naming_convention | true | :internal | :reporter | :accept | :ok + :personal_access_token | :scoped_no_naming_convention | false | :internal | :guest | :accept | :ok + :personal_access_token | :scoped_no_naming_convention | false | :internal | :reporter | :accept | :ok + :personal_access_token | :unscoped | true | :internal | :guest | :accept | :ok + :personal_access_token | :unscoped | true | :internal | :reporter | :accept | :ok + :personal_access_token | :unscoped | false | :internal | :guest | :accept | :ok + :personal_access_token | :unscoped | false | :internal | :reporter | :accept | :ok + :personal_access_token | :non_existing | true | :internal | :guest | :redirect | :redirected + :personal_access_token | :non_existing | true | :internal | :reporter | :redirect | :redirected + :personal_access_token | :non_existing | false | :internal | :guest | :reject | :not_found + :personal_access_token | :non_existing | false | :internal | :reporter | :reject | :not_found + + :job_token | :scoped_naming_convention | true | :public | :developer | :accept | :ok + :job_token | :scoped_naming_convention | false | :public | :developer | :accept | :ok + :job_token | :scoped_no_naming_convention | true | :public | :developer | :accept | :ok + :job_token | :scoped_no_naming_convention | false | :public | :developer | :accept | :ok + :job_token | :unscoped | true | :public | :developer | :accept | :ok + :job_token | :unscoped | false | :public | :developer | :accept | :ok + :job_token | :non_existing | true | :public | :developer | :redirect | :redirected + :job_token | :non_existing | false | :public | :developer | :reject | :not_found + :job_token | :scoped_naming_convention | true | :private | :developer | :accept | :ok + :job_token | :scoped_naming_convention | false | :private | :developer | :accept | :ok + :job_token | :scoped_no_naming_convention | true | :private | :developer | :accept | :ok + :job_token | :scoped_no_naming_convention | false | :private | :developer | :accept | :ok + :job_token | :unscoped | true | :private | :developer | :accept | :ok + :job_token | :unscoped | false | :private | :developer | :accept | :ok + :job_token | :non_existing | true | :private | :developer | :redirect | :redirected + :job_token | :non_existing | false | :private | :developer | :reject | :not_found + :job_token | :scoped_naming_convention | true | :internal | :developer | :accept | :ok + :job_token | :scoped_naming_convention | false | :internal | :developer | :accept | :ok + :job_token | :scoped_no_naming_convention | true | :internal | :developer | :accept | :ok + :job_token | :scoped_no_naming_convention | false | :internal | :developer | :accept | :ok + :job_token | :unscoped | true | :internal | :developer | :accept | :ok + :job_token | :unscoped | false | :internal | :developer | :accept | :ok + :job_token | :non_existing | true | :internal | :developer | :redirect | :redirected + :job_token | :non_existing | false | :internal | :developer | :reject | :not_found + + :deploy_token | :scoped_naming_convention | true | :public | nil | :accept | :ok + :deploy_token | :scoped_naming_convention | false | :public | nil | :accept | :ok + :deploy_token | :scoped_no_naming_convention | true | :public | nil | :accept | :ok + :deploy_token | :scoped_no_naming_convention | false | :public | nil | :accept | :ok + :deploy_token | :unscoped | true | :public | nil | :accept | :ok + :deploy_token | :unscoped | false | :public | nil | :accept | :ok + :deploy_token | :non_existing | true | :public | nil | :redirect | :redirected + :deploy_token | :non_existing | false | :public | nil | :reject | :not_found + :deploy_token | :scoped_naming_convention | true | :private | nil | :accept | :ok + :deploy_token | :scoped_naming_convention | false | :private | nil | :accept | :ok + :deploy_token | :scoped_no_naming_convention | true | :private | nil | :accept | :ok + :deploy_token | :scoped_no_naming_convention | false | :private | nil | :accept | :ok + :deploy_token | :unscoped | true | :private | nil | :accept | :ok + :deploy_token | :unscoped | false | :private | nil | :accept | :ok + :deploy_token | :non_existing | true | :private | nil | :redirect | :redirected + :deploy_token | :non_existing | false | :private | nil | :reject | :not_found + :deploy_token | :scoped_naming_convention | true | :internal | nil | :accept | :ok + :deploy_token | :scoped_naming_convention | false | :internal | nil | :accept | :ok + :deploy_token | :scoped_no_naming_convention | true | :internal | nil | :accept | :ok + :deploy_token | :scoped_no_naming_convention | false | :internal | nil | :accept | :ok + :deploy_token | :unscoped | true | :internal | nil | :accept | :ok + :deploy_token | :unscoped | false | :internal | nil | :accept | :ok + :deploy_token | :non_existing | true | :internal | nil | :redirect | :redirected + :deploy_token | :non_existing | false | :internal | nil | :reject | :not_found + end - with_them do - include_context 'set package name from package name type' - - let(:headers) do - case auth - when :oauth - build_token_auth_header(token.token) - when :personal_access_token - build_token_auth_header(personal_access_token.token) - when :job_token - build_token_auth_header(job.token) - when :deploy_token - build_token_auth_header(deploy_token.token) - else - {} + with_them do + include_context 'set package name from package name type' + + let(:headers) do + case auth + when :oauth + build_token_auth_header(token.token) + when :personal_access_token + build_token_auth_header(personal_access_token.token) + when :job_token + build_token_auth_header(job.token) + when :deploy_token + build_token_auth_header(deploy_token.token) + else + {} + end end - end - before do - project.send("add_#{user_role}", user) if user_role - project.update!(visibility: Gitlab::VisibilityLevel.const_get(visibility, false)) - package.update!(name: package_name) unless package_name == 'non-existing-package' - stub_application_setting(npm_package_requests_forwarding: request_forward) - end + before do + project.send("add_#{user_role}", user) if user_role + project.update!(visibility: visibility.to_s) + package.update!(name: package_name) unless package_name == 'non-existing-package' + stub_application_setting(npm_package_requests_forwarding: request_forward) + end - example_name = "#{params[:expected_result]} metadata request" - status = params[:expected_status] + example_name = "#{params[:expected_result]} metadata request" + status = params[:expected_status] - if scope == :instance && params[:package_name_type] != :scoped_naming_convention - if params[:request_forward] - example_name = 'redirect metadata request' - status = :redirected - else - example_name = 'reject metadata request' - status = :not_found + if scope == :instance && params[:package_name_type] != :scoped_naming_convention + if params[:request_forward] + example_name = 'redirect metadata request' + status = :redirected + else + example_name = 'reject metadata request' + status = :not_found + end end + + it_behaves_like example_name, status: status end + end - it_behaves_like example_name, status: status + context 'with a group namespace' do + it_behaves_like 'handling all conditions' + end + + if scope != :project + context 'with a user namespace' do + let_it_be(:namespace) { user.namespace } + + it_behaves_like 'handling all conditions' + end end context 'with a developer' do @@ -225,26 +323,44 @@ RSpec.shared_examples 'handling get dist tags requests' do |scope: :project| shared_examples 'handling different package names, visibilities and user roles' do where(:package_name_type, :visibility, :user_role, :expected_result, :expected_status) do - :scoped_naming_convention | 'PUBLIC' | :anonymous | :accept | :ok - :scoped_naming_convention | 'PUBLIC' | :guest | :accept | :ok - :scoped_naming_convention | 'PUBLIC' | :reporter | :accept | :ok - :non_existing | 'PUBLIC' | :anonymous | :reject | :not_found - :non_existing | 'PUBLIC' | :guest | :reject | :not_found - :non_existing | 'PUBLIC' | :reporter | :reject | :not_found - - :scoped_naming_convention | 'PRIVATE' | :anonymous | :reject | :not_found - :scoped_naming_convention | 'PRIVATE' | :guest | :reject | :forbidden - :scoped_naming_convention | 'PRIVATE' | :reporter | :accept | :ok - :non_existing | 'PRIVATE' | :anonymous | :reject | :not_found - :non_existing | 'PRIVATE' | :guest | :reject | :forbidden - :non_existing | 'PRIVATE' | :reporter | :reject | :not_found - - :scoped_naming_convention | 'INTERNAL' | :anonymous | :reject | :not_found - :scoped_naming_convention | 'INTERNAL' | :guest | :accept | :ok - :scoped_naming_convention | 'INTERNAL' | :reporter | :accept | :ok - :non_existing | 'INTERNAL' | :anonymous | :reject | :not_found - :non_existing | 'INTERNAL' | :guest | :reject | :not_found - :non_existing | 'INTERNAL' | :reporter | :reject | :not_found + :scoped_naming_convention | :public | :anonymous | :accept | :ok + :scoped_naming_convention | :public | :guest | :accept | :ok + :scoped_naming_convention | :public | :reporter | :accept | :ok + :scoped_no_naming_convention | :public | :anonymous | :accept | :ok + :scoped_no_naming_convention | :public | :guest | :accept | :ok + :scoped_no_naming_convention | :public | :reporter | :accept | :ok + :unscoped | :public | :anonymous | :accept | :ok + :unscoped | :public | :guest | :accept | :ok + :unscoped | :public | :reporter | :accept | :ok + :non_existing | :public | :anonymous | :reject | :not_found + :non_existing | :public | :guest | :reject | :not_found + :non_existing | :public | :reporter | :reject | :not_found + + :scoped_naming_convention | :private | :anonymous | :reject | :not_found + :scoped_naming_convention | :private | :guest | :reject | :forbidden + :scoped_naming_convention | :private | :reporter | :accept | :ok + :scoped_no_naming_convention | :private | :anonymous | :reject | :not_found + :scoped_no_naming_convention | :private | :guest | :reject | :forbidden + :scoped_no_naming_convention | :private | :reporter | :accept | :ok + :unscoped | :private | :anonymous | :reject | :not_found + :unscoped | :private | :guest | :reject | :forbidden + :unscoped | :private | :reporter | :accept | :ok + :non_existing | :private | :anonymous | :reject | :not_found + :non_existing | :private | :guest | :reject | :forbidden + :non_existing | :private | :reporter | :reject | :not_found + + :scoped_naming_convention | :internal | :anonymous | :reject | :not_found + :scoped_naming_convention | :internal | :guest | :accept | :ok + :scoped_naming_convention | :internal | :reporter | :accept | :ok + :scoped_no_naming_convention | :internal | :anonymous | :reject | :not_found + :scoped_no_naming_convention | :internal | :guest | :accept | :ok + :scoped_no_naming_convention | :internal | :reporter | :accept | :ok + :unscoped | :internal | :anonymous | :reject | :not_found + :unscoped | :internal | :guest | :accept | :ok + :unscoped | :internal | :reporter | :accept | :ok + :non_existing | :internal | :anonymous | :reject | :not_found + :non_existing | :internal | :guest | :reject | :not_found + :non_existing | :internal | :reporter | :reject | :not_found end with_them do @@ -254,7 +370,7 @@ RSpec.shared_examples 'handling get dist tags requests' do |scope: :project| before do project.send("add_#{user_role}", user) unless anonymous - project.update!(visibility: Gitlab::VisibilityLevel.const_get(visibility, false)) + project.update!(visibility: visibility.to_s) end example_name = "#{params[:expected_result]} package tags request" @@ -269,16 +385,30 @@ RSpec.shared_examples 'handling get dist tags requests' do |scope: :project| end end - context 'with oauth token' do - let(:headers) { build_token_auth_header(token.token) } + shared_examples 'handling all conditions' do + context 'with oauth token' do + let(:headers) { build_token_auth_header(token.token) } + + it_behaves_like 'handling different package names, visibilities and user roles' + end + + context 'with personal access token' do + let(:headers) { build_token_auth_header(personal_access_token.token) } - it_behaves_like 'handling different package names, visibilities and user roles' + it_behaves_like 'handling different package names, visibilities and user roles' + end end - context 'with personal access token' do - let(:headers) { build_token_auth_header(personal_access_token.token) } + context 'with a group namespace' do + it_behaves_like 'handling all conditions' + end - it_behaves_like 'handling different package names, visibilities and user roles' + if scope != :project + context 'with a user namespace' do + let_it_be(:namespace) { user.namespace } + + it_behaves_like 'handling all conditions' + end end end @@ -303,26 +433,44 @@ RSpec.shared_examples 'handling create dist tag requests' do |scope: :project| shared_examples 'handling different package names, visibilities and user roles' do where(:package_name_type, :visibility, :user_role, :expected_result, :expected_status) do - :scoped_naming_convention | 'PUBLIC' | :anonymous | :reject | :forbidden - :scoped_naming_convention | 'PUBLIC' | :guest | :reject | :forbidden - :scoped_naming_convention | 'PUBLIC' | :developer | :accept | :ok - :non_existing | 'PUBLIC' | :anonymous | :reject | :forbidden - :non_existing | 'PUBLIC' | :guest | :reject | :forbidden - :non_existing | 'PUBLIC' | :developer | :reject | :not_found - - :scoped_naming_convention | 'PRIVATE' | :anonymous | :reject | :not_found - :scoped_naming_convention | 'PRIVATE' | :guest | :reject | :forbidden - :scoped_naming_convention | 'PRIVATE' | :developer | :accept | :ok - :non_existing | 'PRIVATE' | :anonymous | :reject | :not_found - :non_existing | 'PRIVATE' | :guest | :reject | :forbidden - :non_existing | 'PRIVATE' | :developer | :reject | :not_found - - :scoped_naming_convention | 'INTERNAL' | :anonymous | :reject | :forbidden - :scoped_naming_convention | 'INTERNAL' | :guest | :reject | :forbidden - :scoped_naming_convention | 'INTERNAL' | :developer | :accept | :ok - :non_existing | 'INTERNAL' | :anonymous | :reject | :forbidden - :non_existing | 'INTERNAL' | :guest | :reject | :forbidden - :non_existing | 'INTERNAL' | :developer | :reject | :not_found + :scoped_naming_convention | :public | :anonymous | :reject | :forbidden + :scoped_naming_convention | :public | :guest | :reject | :forbidden + :scoped_naming_convention | :public | :developer | :accept | :ok + :scoped_no_naming_convention | :public | :anonymous | :reject | :forbidden + :scoped_no_naming_convention | :public | :guest | :reject | :forbidden + :scoped_no_naming_convention | :public | :developer | :accept | :ok + :unscoped | :public | :anonymous | :reject | :forbidden + :unscoped | :public | :guest | :reject | :forbidden + :unscoped | :public | :developer | :accept | :ok + :non_existing | :public | :anonymous | :reject | :forbidden + :non_existing | :public | :guest | :reject | :forbidden + :non_existing | :public | :developer | :reject | :not_found + + :scoped_naming_convention | :private | :anonymous | :reject | :not_found + :scoped_naming_convention | :private | :guest | :reject | :forbidden + :scoped_naming_convention | :private | :developer | :accept | :ok + :scoped_no_naming_convention | :private | :anonymous | :reject | :not_found + :scoped_no_naming_convention | :private | :guest | :reject | :forbidden + :scoped_no_naming_convention | :private | :developer | :accept | :ok + :unscoped | :private | :anonymous | :reject | :not_found + :unscoped | :private | :guest | :reject | :forbidden + :unscoped | :private | :developer | :accept | :ok + :non_existing | :private | :anonymous | :reject | :not_found + :non_existing | :private | :guest | :reject | :forbidden + :non_existing | :private | :developer | :reject | :not_found + + :scoped_naming_convention | :internal | :anonymous | :reject | :forbidden + :scoped_naming_convention | :internal | :guest | :reject | :forbidden + :scoped_naming_convention | :internal | :developer | :accept | :ok + :scoped_no_naming_convention | :internal | :anonymous | :reject | :forbidden + :scoped_no_naming_convention | :internal | :guest | :reject | :forbidden + :scoped_no_naming_convention | :internal | :developer | :accept | :ok + :unscoped | :internal | :anonymous | :reject | :forbidden + :unscoped | :internal | :guest | :reject | :forbidden + :unscoped | :internal | :developer | :accept | :ok + :non_existing | :internal | :anonymous | :reject | :forbidden + :non_existing | :internal | :guest | :reject | :forbidden + :non_existing | :internal | :developer | :reject | :not_found end with_them do @@ -332,7 +480,7 @@ RSpec.shared_examples 'handling create dist tag requests' do |scope: :project| before do project.send("add_#{user_role}", user) unless anonymous - project.update!(visibility: Gitlab::VisibilityLevel.const_get(visibility, false)) + project.update!(visibility: visibility.to_s) end example_name = "#{params[:expected_result]} create package tag request" @@ -347,16 +495,30 @@ RSpec.shared_examples 'handling create dist tag requests' do |scope: :project| end end - context 'with oauth token' do - let(:headers) { build_token_auth_header(token.token) } + shared_examples 'handling all conditions' do + context 'with oauth token' do + let(:headers) { build_token_auth_header(token.token) } - it_behaves_like 'handling different package names, visibilities and user roles' + it_behaves_like 'handling different package names, visibilities and user roles' + end + + context 'with personal access token' do + let(:headers) { build_token_auth_header(personal_access_token.token) } + + it_behaves_like 'handling different package names, visibilities and user roles' + end end - context 'with personal access token' do - let(:headers) { build_token_auth_header(personal_access_token.token) } + context 'with a group namespace' do + it_behaves_like 'handling all conditions' + end - it_behaves_like 'handling different package names, visibilities and user roles' + if scope != :project + context 'with a user namespace' do + let_it_be(:namespace) { user.namespace } + + it_behaves_like 'handling all conditions' + end end end @@ -379,19 +541,44 @@ RSpec.shared_examples 'handling delete dist tag requests' do |scope: :project| shared_examples 'handling different package names, visibilities and user roles' do where(:package_name_type, :visibility, :user_role, :expected_result, :expected_status) do - :scoped_naming_convention | 'PUBLIC' | :anonymous | :reject | :forbidden - :scoped_naming_convention | 'PUBLIC' | :guest | :reject | :forbidden - :scoped_naming_convention | 'PUBLIC' | :maintainer | :accept | :ok - :non_existing | 'PUBLIC' | :anonymous | :reject | :forbidden - :non_existing | 'PUBLIC' | :guest | :reject | :forbidden - :non_existing | 'PUBLIC' | :maintainer | :reject | :not_found - - :scoped_naming_convention | 'PRIVATE' | :anonymous | :reject | :not_found - :scoped_naming_convention | 'PRIVATE' | :guest | :reject | :forbidden - :scoped_naming_convention | 'PRIVATE' | :maintainer | :accept | :ok - :non_existing | 'INTERNAL' | :anonymous | :reject | :forbidden - :non_existing | 'INTERNAL' | :guest | :reject | :forbidden - :non_existing | 'INTERNAL' | :maintainer | :reject | :not_found + :scoped_naming_convention | :public | :anonymous | :reject | :forbidden + :scoped_naming_convention | :public | :guest | :reject | :forbidden + :scoped_naming_convention | :public | :maintainer | :accept | :ok + :scoped_no_naming_convention | :public | :anonymous | :reject | :forbidden + :scoped_no_naming_convention | :public | :guest | :reject | :forbidden + :scoped_no_naming_convention | :public | :maintainer | :accept | :ok + :unscoped | :public | :anonymous | :reject | :forbidden + :unscoped | :public | :guest | :reject | :forbidden + :unscoped | :public | :maintainer | :accept | :ok + :non_existing | :public | :anonymous | :reject | :forbidden + :non_existing | :public | :guest | :reject | :forbidden + :non_existing | :public | :maintainer | :reject | :not_found + + :scoped_naming_convention | :private | :anonymous | :reject | :not_found + :scoped_naming_convention | :private | :guest | :reject | :forbidden + :scoped_naming_convention | :private | :maintainer | :accept | :ok + :scoped_no_naming_convention | :private | :anonymous | :reject | :not_found + :scoped_no_naming_convention | :private | :guest | :reject | :forbidden + :scoped_no_naming_convention | :private | :maintainer | :accept | :ok + :unscoped | :private | :anonymous | :reject | :not_found + :unscoped | :private | :guest | :reject | :forbidden + :unscoped | :private | :maintainer | :accept | :ok + :non_existing | :private | :anonymous | :reject | :not_found + :non_existing | :private | :guest | :reject | :forbidden + :non_existing | :private | :maintainer | :reject | :not_found + + :scoped_naming_convention | :internal | :anonymous | :reject | :forbidden + :scoped_naming_convention | :internal | :guest | :reject | :forbidden + :scoped_naming_convention | :internal | :maintainer | :accept | :ok + :scoped_no_naming_convention | :internal | :anonymous | :reject | :forbidden + :scoped_no_naming_convention | :internal | :guest | :reject | :forbidden + :scoped_no_naming_convention | :internal | :maintainer | :accept | :ok + :unscoped | :internal | :anonymous | :reject | :forbidden + :unscoped | :internal | :guest | :reject | :forbidden + :unscoped | :internal | :maintainer | :accept | :ok + :non_existing | :internal | :anonymous | :reject | :forbidden + :non_existing | :internal | :guest | :reject | :forbidden + :non_existing | :internal | :maintainer | :reject | :not_found end with_them do @@ -401,7 +588,7 @@ RSpec.shared_examples 'handling delete dist tag requests' do |scope: :project| before do project.send("add_#{user_role}", user) unless anonymous - project.update!(visibility: Gitlab::VisibilityLevel.const_get(visibility, false)) + project.update!(visibility: visibility.to_s) end example_name = "#{params[:expected_result]} delete package tag request" @@ -416,15 +603,29 @@ RSpec.shared_examples 'handling delete dist tag requests' do |scope: :project| end end - context 'with oauth token' do - let(:headers) { build_token_auth_header(token.token) } + shared_examples 'handling all conditions' do + context 'with oauth token' do + let(:headers) { build_token_auth_header(token.token) } + + it_behaves_like 'handling different package names, visibilities and user roles' + end + + context 'with personal access token' do + let(:headers) { build_token_auth_header(personal_access_token.token) } - it_behaves_like 'handling different package names, visibilities and user roles' + it_behaves_like 'handling different package names, visibilities and user roles' + end end - context 'with personal access token' do - let(:headers) { build_token_auth_header(personal_access_token.token) } + context 'with a group namespace' do + it_behaves_like 'handling all conditions' + end - it_behaves_like 'handling different package names, visibilities and user roles' + if scope != :project + context 'with a user namespace' do + let_it_be(:namespace) { user.namespace } + + it_behaves_like 'handling all conditions' + end end end diff --git a/spec/support/shared_examples/requests/api/rubygems_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/rubygems_packages_shared_examples.rb new file mode 100644 index 00000000000..15fb6611b90 --- /dev/null +++ b/spec/support/shared_examples/requests/api/rubygems_packages_shared_examples.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'rejects rubygems packages access' 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 + end + + it_behaves_like 'returning response status', status + end +end + +RSpec.shared_examples 'process rubygems workhorse authorization' 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 + end + + it_behaves_like 'returning response status', status + + it 'has the proper content type' do + subject + + expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end + + context 'with a request that bypassed gitlab-workhorse' do + let(:headers) do + { 'HTTP_AUTHORIZATION' => personal_access_token.token } + .merge(workhorse_headers) + .tap { |h| h.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER) } + end + + before do + project.add_maintainer(user) + end + + it_behaves_like 'returning response status', :forbidden + end + end +end + +RSpec.shared_examples 'process rubygems upload' do |user_type, status, add_member = true| + RSpec.shared_examples 'creates rubygems package files' do + it 'creates package files', :aggregate_failures do + expect { subject } + .to change { project.packages.count }.by(1) + .and change { Packages::PackageFile.count }.by(1) + expect(response).to have_gitlab_http_status(status) + + package_file = project.packages.last.package_files.reload.last + expect(package_file.file_name).to eq('package.gem') + end + end + + context "for user type #{user_type}" do + before do + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + context 'with object storage disabled' do + before do + stub_package_file_object_storage(enabled: false) + end + + context 'without a file from workhorse' do + let(:send_rewritten_field) { false } + + it_behaves_like 'returning response status', :bad_request + end + + context 'with correct params' do + it_behaves_like 'package workhorse uploads' + it_behaves_like 'creates rubygems package files' + it_behaves_like 'a package tracking event', 'API::RubygemPackages', 'push_package' + end + end + + context 'with object storage enabled' do + let(:tmp_object) do + fog_connection.directories.new(key: 'packages').files.create( # rubocop:disable Rails/SaveBang + key: "tmp/uploads/#{file_name}", + body: 'content' + ) + end + + let(:fog_file) { fog_to_uploaded_file(tmp_object) } + let(:params) { { file: fog_file, 'file.remote_id' => file_name } } + + context 'and direct upload enabled' do + let(:fog_connection) do + stub_package_file_object_storage(direct_upload: true) + end + + it_behaves_like 'creates rubygems package files' + + ['123123', '../../123123'].each do |remote_id| + context "with invalid remote_id: #{remote_id}" do + let(:params) do + { + file: fog_file, + 'file.remote_id' => remote_id + } + end + + it_behaves_like 'returning response status', :forbidden + end + end + end + + context 'and direct upload disabled' do + context 'and background upload disabled' do + let(:fog_connection) do + stub_package_file_object_storage(direct_upload: false, background_upload: false) + end + + it_behaves_like 'creates rubygems package files' + end + + context 'and background upload enabled' do + let(:fog_connection) do + stub_package_file_object_storage(direct_upload: false, background_upload: true) + end + + it_behaves_like 'creates rubygems package files' + end + end + end + end +end + +RSpec.shared_examples 'dependency endpoint success' 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 + end + + raise 'Status is not :success' if status != :success + + context 'with no params', :aggregate_failures do + it 'returns empty' do + subject + + expect(response.body).to eq('200') + expect(response).to have_gitlab_http_status(status) + end + end + + context 'with gems params' do + let(:params) { { gems: 'foo,bar' } } + let(:expected_response) { Marshal.dump(%w(result result)) } + + it 'returns successfully', :aggregate_failures do + service_result = double('DependencyResolverService', execute: ServiceResponse.success(payload: 'result')) + + expect(Packages::Rubygems::DependencyResolverService).to receive(:new).with(project, anything, gem_name: 'foo').and_return(service_result) + expect(Packages::Rubygems::DependencyResolverService).to receive(:new).with(project, anything, gem_name: 'bar').and_return(service_result) + + subject + + expect(response.body).to eq(expected_response) # rubocop:disable Security/MarshalLoad + expect(response).to have_gitlab_http_status(status) + end + + it 'rejects if the service fails', :aggregate_failures do + service_result = double('DependencyResolverService', execute: ServiceResponse.error(message: 'rejected', http_status: :bad_request)) + + expect(Packages::Rubygems::DependencyResolverService).to receive(:new).with(project, anything, gem_name: 'foo').and_return(service_result) + + subject + + expect(response.body).to match(/rejected/) + expect(response).to have_gitlab_http_status(:bad_request) + end + end + end +end + +RSpec.shared_examples 'Rubygems gem download' 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 + end + + it 'returns the gem', :aggregate_failures do + subject + + expect(response.media_type).to eq('application/octet-stream') + expect(response).to have_gitlab_http_status(status) + end + + it_behaves_like 'a package tracking event', described_class.name, 'pull_package' + end +end diff --git a/spec/support/shared_examples/service_desk_issue_templates_examples.rb b/spec/support/shared_examples/service_desk_issue_templates_examples.rb new file mode 100644 index 00000000000..fd9645df7a3 --- /dev/null +++ b/spec/support/shared_examples/service_desk_issue_templates_examples.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'issue description templates from current project only' do + it 'loads issue description templates from the project only' do + within('#service-desk-template-select') do + expect(page).to have_content('project-issue-bar') + expect(page).to have_content('project-issue-foo') + expect(page).not_to have_content('group-issue-bar') + expect(page).not_to have_content('group-issue-foo') + end + end +end diff --git a/spec/support/shared_examples/services/boards/update_boards_shared_examples.rb b/spec/support/shared_examples/services/boards/update_boards_shared_examples.rb new file mode 100644 index 00000000000..cd773a2a04a --- /dev/null +++ b/spec/support/shared_examples/services/boards/update_boards_shared_examples.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'board update service' do + subject(:service) { described_class.new(board.resource_parent, user, all_params) } + + it 'updates the board with valid params' do + result = described_class.new(group, user, name: 'Engineering').execute(board) + + expect(result).to eq(true) + expect(board.reload.name).to eq('Engineering') + end + + it 'does not update the board with invalid params' do + orig_name = board.name + + result = described_class.new(group, user, name: nil).execute(board) + + expect(result).to eq(false) + expect(board.reload.name).to eq(orig_name) + end + + context 'with scoped_issue_board available' do + before do + stub_licensed_features(scoped_issue_board: true) + end + + context 'user is member of the board parent' do + before do + board.resource_parent.add_reporter(user) + end + + it 'updates the configuration params when scoped issue board is enabled' do + service.execute(board) + + labels = updated_scoped_params.delete(:labels) + expect(board.reload).to have_attributes(updated_scoped_params) + expect(board.labels).to match_array(labels) + end + end + + context 'when labels param is used' do + let(:params) { { labels: [label.name, parent_label.name, 'new label'].join(',') } } + + subject(:service) { described_class.new(board.resource_parent, user, params) } + + context 'when user can create new labels' do + before do + board.resource_parent.add_reporter(user) + end + + it 'adds labels to the board' do + service.execute(board) + + expect(board.reload.labels.map(&:name)).to match_array([label.name, parent_label.name, 'new label']) + end + end + + context 'when user can not create new labels' do + before do + board.resource_parent.add_guest(user) + end + + it 'adds only existing labels to the board' do + service.execute(board) + + expect(board.reload.labels.map(&:name)).to match_array([label.name, parent_label.name]) + end + end + end + end + + context 'without scoped_issue_board available' do + before do + stub_licensed_features(scoped_issue_board: false) + end + + it 'filters unpermitted params when scoped issue board is not enabled' do + service.execute(board) + + expect(board.reload).to have_attributes(updated_without_scoped_params) + end + end +end diff --git a/spec/support/shared_examples/services/packages/maven/metadata_shared_examples.rb b/spec/support/shared_examples/services/packages/maven/metadata_shared_examples.rb new file mode 100644 index 00000000000..4de672bb732 --- /dev/null +++ b/spec/support/shared_examples/services/packages/maven/metadata_shared_examples.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'handling metadata content pointing to a file for the create xml service' do + context 'with metadata content pointing to a file' do + let(:service) { described_class.new(metadata_content: file, package: package) } + let(:file) do + Tempfile.new('metadata').tap do |file| + if file_contents + file.write(file_contents) + file.flush + file.rewind + end + end + end + + after do + file.close + file.unlink + end + + context 'with valid content' do + let(:file_contents) { metadata_xml } + + it 'returns no changes' do + expect(subject).to be_success + expect(subject.payload).to eq(changes_exist: false, empty_versions: false) + end + end + + context 'with invalid content' do + let(:file_contents) { '<meta></metadata>' } + + it_behaves_like 'returning an error service response', message: 'metadata_content is invalid' + end + + context 'with no content' do + let(:file_contents) { nil } + + it_behaves_like 'returning an error service response', message: 'metadata_content is invalid' + end + end +end + +RSpec.shared_examples 'handling invalid parameters for create xml service' do + context 'with no package' do + let(:metadata_xml) { '' } + let(:package) { nil } + + it_behaves_like 'returning an error service response', message: 'package not set' + end + + context 'with no metadata content' do + let(:metadata_xml) { nil } + + it_behaves_like 'returning an error service response', message: 'metadata_content not set' + end +end diff --git a/spec/support/snowplow.rb b/spec/support/snowplow.rb index 0d6102f1705..e58be667b37 100644 --- a/spec/support/snowplow.rb +++ b/spec/support/snowplow.rb @@ -1,24 +1,13 @@ # frozen_string_literal: true +require_relative 'stub_snowplow' + RSpec.configure do |config| config.include SnowplowHelpers, :snowplow + config.include StubSnowplow, :snowplow config.before(:each, :snowplow) do - # Using a high buffer size to not cause early flushes - buffer_size = 100 - # WebMock is set up to allow requests to `localhost` - host = 'localhost' - - allow_any_instance_of(Gitlab::Tracking::Destinations::ProductAnalytics).to receive(:event) - - allow_any_instance_of(Gitlab::Tracking::Destinations::Snowplow) - .to receive(:emitter) - .and_return(SnowplowTracker::Emitter.new(host, buffer_size: buffer_size)) - - stub_application_setting(snowplow_enabled: true) - - allow(SnowplowTracker::SelfDescribingJson).to receive(:new).and_call_original - allow(Gitlab::Tracking).to receive(:event).and_call_original # rubocop:disable RSpec/ExpectGitlabTracking + stub_snowplow end config.after(:each, :snowplow) do diff --git a/spec/support/stub_snowplow.rb b/spec/support/stub_snowplow.rb new file mode 100644 index 00000000000..a21ce2399d7 --- /dev/null +++ b/spec/support/stub_snowplow.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module StubSnowplow + def stub_snowplow + # Using a high buffer size to not cause early flushes + buffer_size = 100 + # WebMock is set up to allow requests to `localhost` + host = 'localhost' + + # rubocop:disable RSpec/AnyInstanceOf + allow_any_instance_of(Gitlab::Tracking::Destinations::ProductAnalytics).to receive(:event) + + allow_any_instance_of(Gitlab::Tracking::Destinations::Snowplow) + .to receive(:emitter) + .and_return(SnowplowTracker::Emitter.new(host, buffer_size: buffer_size)) + # rubocop:enable RSpec/AnyInstanceOf + + stub_application_setting(snowplow_enabled: true) + + allow(SnowplowTracker::SelfDescribingJson).to receive(:new).and_call_original + allow(Gitlab::Tracking).to receive(:event).and_call_original # rubocop:disable RSpec/ExpectGitlabTracking + end +end diff --git a/spec/tasks/admin_mode_spec.rb b/spec/tasks/admin_mode_spec.rb new file mode 100644 index 00000000000..9dd35650ab6 --- /dev/null +++ b/spec/tasks/admin_mode_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'rake_helper' + +RSpec.describe 'admin mode on tasks' do + before do + allow(::Gitlab::Runtime).to receive(:test_suite?).and_return(false) + allow(::Gitlab::Runtime).to receive(:rake?).and_return(true) + end + + shared_examples 'verify admin mode' do |state| + it 'matches the expected admin mode' do + Rake::Task.define_task :verify_admin_mode do + expect(Gitlab::Auth::CurrentUserMode.new(user).admin_mode?).to be(state) + end + + run_rake_task('verify_admin_mode') + end + end + + describe 'with a regular user' do + let(:user) { create(:user) } + + include_examples 'verify admin mode', false + end + + describe 'with an admin' do + let(:user) { create(:admin) } + + include_examples 'verify admin mode', true + end +end diff --git a/spec/tasks/gitlab/packages/composer_rake_spec.rb b/spec/tasks/gitlab/packages/composer_rake_spec.rb new file mode 100644 index 00000000000..d54e1b02599 --- /dev/null +++ b/spec/tasks/gitlab/packages/composer_rake_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'rake_helper' + +RSpec.describe 'gitlab:packages:build_composer_cache namespace rake task' do + let_it_be(:package_name) { 'sample-project' } + let_it_be(:package_name2) { 'sample-project2' } + let_it_be(:json) { { 'name' => package_name } } + let_it_be(:json2) { { 'name' => package_name2 } } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :custom_repo, files: { 'composer.json' => json.to_json }, group: group) } + let_it_be(:project2) { create(:project, :custom_repo, files: { 'composer.json' => json2.to_json }, group: group) } + let!(:package) { create(:composer_package, :with_metadatum, project: project, name: package_name, version: '1.0.0', json: json) } + let!(:package2) { create(:composer_package, :with_metadatum, project: project, name: package_name, version: '2.0.0', json: json) } + let!(:package3) { create(:composer_package, :with_metadatum, project: project2, name: package_name2, version: '3.0.0', json: json2) } + + before :all do + Rake.application.rake_require 'tasks/gitlab/packages/composer' + end + + subject do + run_rake_task("gitlab:packages:build_composer_cache") + end + + it 'generates the cache files' do + expect { subject }.to change { Packages::Composer::CacheFile.count }.by(2) + end +end diff --git a/spec/tooling/danger/base_linter_spec.rb b/spec/tooling/danger/base_linter_spec.rb deleted file mode 100644 index 54d8f3dc1f7..00000000000 --- a/spec/tooling/danger/base_linter_spec.rb +++ /dev/null @@ -1,192 +0,0 @@ -# frozen_string_literal: true - -require 'rspec-parameterized' -require_relative 'danger_spec_helper' - -require_relative '../../../tooling/danger/base_linter' - -RSpec.describe Tooling::Danger::BaseLinter do - let(:commit_class) do - Struct.new(:message, :sha, :diff_parent) - end - - let(:commit_message) { 'A commit message' } - let(:commit) { commit_class.new(commit_message, anything, anything) } - - subject(:commit_linter) { described_class.new(commit) } - - describe '#failed?' do - context 'with no failures' do - it { expect(commit_linter).not_to be_failed } - end - - context 'with failures' do - before do - commit_linter.add_problem(:subject_too_long, described_class.subject_description) - end - - it { expect(commit_linter).to be_failed } - end - end - - describe '#add_problem' do - it 'stores messages in #failures' do - commit_linter.add_problem(:subject_too_long, '%s') - - expect(commit_linter.problems).to eq({ subject_too_long: described_class.problems_mapping[:subject_too_long] }) - end - end - - shared_examples 'a valid commit' do - it 'does not have any problem' do - commit_linter.lint_subject - - expect(commit_linter.problems).to be_empty - end - end - - describe '#lint_subject' do - context 'when subject valid' do - it_behaves_like 'a valid commit' - end - - context 'when subject is too short' do - let(:commit_message) { 'A B' } - - it 'adds a problem' do - expect(commit_linter).to receive(:add_problem).with(:subject_too_short, described_class.subject_description) - - commit_linter.lint_subject - end - end - - context 'when subject is too long' do - let(:commit_message) { 'A B ' + 'C' * described_class::MAX_LINE_LENGTH } - - it 'adds a problem' do - expect(commit_linter).to receive(:add_problem).with(:subject_too_long, described_class.subject_description) - - commit_linter.lint_subject - end - end - - context 'when ignoring length issues for subject having not-ready wording' do - using RSpec::Parameterized::TableSyntax - - let(:final_message) { 'A B C' } - - context 'when used as prefix' do - where(prefix: [ - 'WIP: ', - 'WIP:', - 'wIp:', - '[WIP] ', - '[WIP]', - '[draft]', - '[draft] ', - '(draft)', - '(draft) ', - 'draft - ', - 'draft: ', - 'draft:', - 'DRAFT:' - ]) - - with_them do - it 'does not have any problems' do - commit_message = prefix + final_message + 'D' * (described_class::MAX_LINE_LENGTH - final_message.size) - commit = commit_class.new(commit_message, anything, anything) - - linter = described_class.new(commit).lint_subject - - expect(linter.problems).to be_empty - end - end - end - - context 'when used as suffix' do - where(suffix: %w[WIP draft]) - - with_them do - it 'does not have any problems' do - commit_message = final_message + 'D' * (described_class::MAX_LINE_LENGTH - final_message.size) + suffix - commit = commit_class.new(commit_message, anything, anything) - - linter = described_class.new(commit).lint_subject - - expect(linter.problems).to be_empty - end - end - end - end - - context 'when subject does not have enough words and is too long' do - let(:commit_message) { 'A ' + 'B' * described_class::MAX_LINE_LENGTH } - - it 'adds a problem' do - expect(commit_linter).to receive(:add_problem).with(:subject_too_short, described_class.subject_description) - expect(commit_linter).to receive(:add_problem).with(:subject_too_long, described_class.subject_description) - - commit_linter.lint_subject - end - end - - context 'when subject starts with lowercase' do - let(:commit_message) { 'a B C' } - - it 'adds a problem' do - expect(commit_linter).to receive(:add_problem).with(:subject_starts_with_lowercase, described_class.subject_description) - - commit_linter.lint_subject - end - end - - [ - '[ci skip] A commit message', - '[Ci skip] A commit message', - '[API] A commit message', - 'api: A commit message', - 'API: A commit message', - 'API: a commit message', - 'API: a commit message' - ].each do |message| - context "when subject is '#{message}'" do - let(:commit_message) { message } - - it 'does not add a problem' do - expect(commit_linter).not_to receive(:add_problem) - - commit_linter.lint_subject - end - end - end - - [ - '[ci skip]A commit message', - '[Ci skip] A commit message', - '[ci skip] a commit message', - 'api: a commit message', - '! A commit message' - ].each do |message| - context "when subject is '#{message}'" do - let(:commit_message) { message } - - it 'adds a problem' do - expect(commit_linter).to receive(:add_problem).with(:subject_starts_with_lowercase, described_class.subject_description) - - commit_linter.lint_subject - end - end - end - - context 'when subject ends with a period' do - let(:commit_message) { 'A B C.' } - - it 'adds a problem' do - expect(commit_linter).to receive(:add_problem).with(:subject_ends_with_a_period, described_class.subject_description) - - commit_linter.lint_subject - end - end - end -end diff --git a/spec/tooling/danger/changelog_spec.rb b/spec/tooling/danger/changelog_spec.rb index c0eca67ce92..b74039b3cd1 100644 --- a/spec/tooling/danger/changelog_spec.rb +++ b/spec/tooling/danger/changelog_spec.rb @@ -1,51 +1,76 @@ # frozen_string_literal: true -require_relative 'danger_spec_helper' +require 'gitlab-dangerfiles' +require 'gitlab/dangerfiles/spec_helper' require_relative '../../../tooling/danger/changelog' +require_relative '../../../tooling/danger/project_helper' RSpec.describe Tooling::Danger::Changelog do - include DangerSpecHelper + include_context "with dangerfile" - let(:added_files) { nil } - let(:fake_git) { double('fake-git', added_files: added_files) } + let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) } + let(:fake_project_helper) { double('fake-project-helper', helper: fake_helper).tap { |h| h.class.include(Tooling::Danger::ProjectHelper) } } - let(:mr_labels) { nil } - let(:mr_json) { nil } - let(:fake_gitlab) { double('fake-gitlab', mr_labels: mr_labels, mr_json: mr_json) } + subject(:changelog) { fake_danger.new(helper: fake_helper) } - let(:changes_by_category) { nil } - let(:sanitize_mr_title) { nil } - let(:ee?) { false } - let(:fake_helper) { double('fake-helper', changes_by_category: changes_by_category, sanitize_mr_title: sanitize_mr_title, ee?: ee?) } + before do + allow(changelog).to receive(:project_helper).and_return(fake_project_helper) + end + + describe '#required_reasons' do + subject { changelog.required_reasons } + + context "added files contain a migration" do + let(:changes) { changes_class.new([change_class.new('foo', :added, :migration)]) } + + it { is_expected.to include(:db_changes) } + end + + context "removed files contains a feature flag" do + let(:changes) { changes_class.new([change_class.new('foo', :deleted, :feature_flag)]) } - let(:fake_danger) { new_fake_danger.include(described_class) } + it { is_expected.to include(:feature_flag_removed) } + end + + context "added files do not contain a migration" do + let(:changes) { changes_class.new([change_class.new('foo', :added, :frontend)]) } + + it { is_expected.to be_empty } + end + + context "removed files do not contain a feature flag" do + let(:changes) { changes_class.new([change_class.new('foo', :deleted, :backend)]) } - subject(:changelog) { fake_danger.new(git: fake_git, gitlab: fake_gitlab, helper: fake_helper) } + it { is_expected.to be_empty } + end + end describe '#required?' do subject { changelog.required? } context 'added files contain a migration' do - [ - 'db/migrate/20200000000000_new_migration.rb', - 'db/post_migrate/20200000000000_new_migration.rb' - ].each do |file_path| - let(:added_files) { [file_path] } + let(:changes) { changes_class.new([change_class.new('foo', :added, :migration)]) } - it { is_expected.to be_truthy } - end + it { is_expected.to be_truthy } + end + + context "removed files contains a feature flag" do + let(:changes) { changes_class.new([change_class.new('foo', :deleted, :feature_flag)]) } + + it { is_expected.to be_truthy } end context 'added files do not contain a migration' do - [ - 'app/models/model.rb', - 'app/assets/javascripts/file.js' - ].each do |file_path| - let(:added_files) { [file_path] } + let(:changes) { changes_class.new([change_class.new('foo', :added, :frontend)]) } - it { is_expected.to be_falsey } - end + it { is_expected.to be_falsey } + end + + context "removed files do not contain a feature flag" do + let(:changes) { changes_class.new([change_class.new('foo', :deleted, :backend)]) } + + it { is_expected.to be_falsey } end end @@ -58,8 +83,7 @@ RSpec.describe Tooling::Danger::Changelog do subject { changelog.optional? } context 'when MR contains only categories requiring no changelog' do - let(:changes_by_category) { { category_without_changelog => nil } } - let(:mr_labels) { [] } + let(:changes) { changes_class.new([change_class.new('foo', :modified, category_without_changelog)]) } it 'is falsey' do is_expected.to be_falsy @@ -67,7 +91,7 @@ RSpec.describe Tooling::Danger::Changelog do end context 'when MR contains a label that require no changelog' do - let(:changes_by_category) { { category_with_changelog => nil } } + let(:changes) { changes_class.new([change_class.new('foo', :modified, category_with_changelog)]) } let(:mr_labels) { [label_with_changelog, label_without_changelog] } it 'is falsey' do @@ -76,29 +100,28 @@ RSpec.describe Tooling::Danger::Changelog do end context 'when MR contains a category that require changelog and a category that require no changelog' do - let(:changes_by_category) { { category_with_changelog => nil, category_without_changelog => nil } } - let(:mr_labels) { [] } + let(:changes) { changes_class.new([change_class.new('foo', :modified, category_with_changelog), change_class.new('foo', :modified, category_without_changelog)]) } - it 'is truthy' do - is_expected.to be_truthy + context 'with no labels' do + it 'is truthy' do + is_expected.to be_truthy + end end - end - context 'when MR contains a category that require changelog and a category that require no changelog with changelog label' do - let(:changes_by_category) { { category_with_changelog => nil, category_without_changelog => nil } } - let(:mr_labels) { ['feature'] } + context 'with changelog label' do + let(:mr_labels) { ['feature'] } - it 'is truthy' do - is_expected.to be_truthy + it 'is truthy' do + is_expected.to be_truthy + end end - end - context 'when MR contains a category that require changelog and a category that require no changelog with no changelog label' do - let(:changes_by_category) { { category_with_changelog => nil, category_without_changelog => nil } } - let(:mr_labels) { ['tooling'] } + context 'with no changelog label' do + let(:mr_labels) { ['tooling'] } - it 'is truthy' do - is_expected.to be_falsey + it 'is truthy' do + is_expected.to be_falsey + end end end end @@ -107,54 +130,39 @@ RSpec.describe Tooling::Danger::Changelog do subject { changelog.found } context 'added files contain a changelog' do - [ - 'changelogs/unreleased/entry.yml', - 'ee/changelogs/unreleased/entry.yml' - ].each do |file_path| - let(:added_files) { [file_path] } + let(:changes) { changes_class.new([change_class.new('foo', :added, :changelog)]) } - it { is_expected.to be_truthy } - end + it { is_expected.to be_truthy } end context 'added files do not contain a changelog' do - [ - 'app/models/model.rb', - 'app/assets/javascripts/file.js' - ].each do |file_path| - let(:added_files) { [file_path] } - it { is_expected.to eq(nil) } - end + let(:changes) { changes_class.new([change_class.new('foo', :added, :backend)]) } + + it { is_expected.to eq(nil) } end end describe '#ee_changelog?' do subject { changelog.ee_changelog? } - before do - allow(changelog).to receive(:found).and_return(file_path) - end - context 'is ee changelog' do - let(:file_path) { 'ee/changelogs/unreleased/entry.yml' } + let(:changes) { changes_class.new([change_class.new('ee/changelogs/unreleased/entry.yml', :added, :changelog)]) } it { is_expected.to be_truthy } end context 'is not ee changelog' do - let(:file_path) { 'changelogs/unreleased/entry.yml' } + let(:changes) { changes_class.new([change_class.new('changelogs/unreleased/entry.yml', :added, :changelog)]) } it { is_expected.to be_falsy } end end describe '#modified_text' do - let(:mr_json) { { "iid" => 1234, "title" => sanitize_mr_title } } - subject { changelog.modified_text } context "when title is not changed from sanitization", :aggregate_failures do - let(:sanitize_mr_title) { 'Fake Title' } + let(:mr_title) { 'Fake Title' } specify do expect(subject).to include('CHANGELOG.md was edited') @@ -164,7 +172,7 @@ RSpec.describe Tooling::Danger::Changelog do end context "when title needs sanitization", :aggregate_failures do - let(:sanitize_mr_title) { 'DRAFT: Fake Title' } + let(:mr_title) { 'DRAFT: Fake Title' } specify do expect(subject).to include('CHANGELOG.md was edited') @@ -174,39 +182,46 @@ RSpec.describe Tooling::Danger::Changelog do end end - describe '#required_text' do - let(:mr_json) { { "iid" => 1234, "title" => sanitize_mr_title } } - - subject { changelog.required_text } + describe '#required_texts' do + let(:mr_title) { 'Fake Title' } - context "when title is not changed from sanitization", :aggregate_failures do - let(:sanitize_mr_title) { 'Fake Title' } + subject { changelog.required_texts } + shared_examples 'changelog required text' do |key| specify do - expect(subject).to include('CHANGELOG missing') - expect(subject).to include('bin/changelog -m 1234 "Fake Title"') - expect(subject).not_to include('--ee') + expect(subject).to have_key(key) + expect(subject[key]).to include('CHANGELOG missing') + expect(subject[key]).to include('bin/changelog -m 1234 "Fake Title"') + expect(subject[key]).not_to include('--ee') end end - context "when title needs sanitization", :aggregate_failures do - let(:sanitize_mr_title) { 'DRAFT: Fake Title' } + context 'with a new migration file' do + let(:changes) { changes_class.new([change_class.new('foo', :added, :migration)]) } - specify do - expect(subject).to include('CHANGELOG missing') - expect(subject).to include('bin/changelog -m 1234 "Fake Title"') - expect(subject).not_to include('--ee') + context "when title is not changed from sanitization", :aggregate_failures do + it_behaves_like 'changelog required text', :db_changes + end + + context "when title needs sanitization", :aggregate_failures do + let(:mr_title) { 'DRAFT: Fake Title' } + + it_behaves_like 'changelog required text', :db_changes end end + + context 'with a removed feature flag file' do + let(:changes) { changes_class.new([change_class.new('foo', :deleted, :feature_flag)]) } + + it_behaves_like 'changelog required text', :feature_flag_removed + end end describe '#optional_text' do - let(:mr_json) { { "iid" => 1234, "title" => sanitize_mr_title } } - subject { changelog.optional_text } context "when title is not changed from sanitization", :aggregate_failures do - let(:sanitize_mr_title) { 'Fake Title' } + let(:mr_title) { 'Fake Title' } specify do expect(subject).to include('CHANGELOG missing') @@ -216,7 +231,7 @@ RSpec.describe Tooling::Danger::Changelog do end context "when title needs sanitization", :aggregate_failures do - let(:sanitize_mr_title) { 'DRAFT: Fake Title' } + let(:mr_title) { 'DRAFT: Fake Title' } specify do expect(subject).to include('CHANGELOG missing') diff --git a/spec/tooling/danger/commit_linter_spec.rb b/spec/tooling/danger/commit_linter_spec.rb deleted file mode 100644 index 694e524af21..00000000000 --- a/spec/tooling/danger/commit_linter_spec.rb +++ /dev/null @@ -1,241 +0,0 @@ -# frozen_string_literal: true - -require 'rspec-parameterized' -require_relative 'danger_spec_helper' - -require_relative '../../../tooling/danger/commit_linter' - -RSpec.describe Tooling::Danger::CommitLinter do - using RSpec::Parameterized::TableSyntax - - let(:total_files_changed) { 2 } - let(:total_lines_changed) { 10 } - let(:stats) { { total: { files: total_files_changed, lines: total_lines_changed } } } - let(:diff_parent) { Struct.new(:stats).new(stats) } - let(:commit_class) do - Struct.new(:message, :sha, :diff_parent) - end - - let(:commit_message) { 'A commit message' } - let(:commit_sha) { 'abcd1234' } - let(:commit) { commit_class.new(commit_message, commit_sha, diff_parent) } - - subject(:commit_linter) { described_class.new(commit) } - - describe '#fixup?' do - where(:commit_message, :is_fixup) do - 'A commit message' | false - 'fixup!' | true - 'fixup! A commit message' | true - 'squash!' | true - 'squash! A commit message' | true - end - - with_them do - it 'is true when commit message starts with "fixup!" or "squash!"' do - expect(commit_linter.fixup?).to be(is_fixup) - end - end - end - - describe '#suggestion?' do - where(:commit_message, :is_suggestion) do - 'A commit message' | false - 'Apply suggestion to' | true - 'Apply suggestion to "A commit message"' | true - end - - with_them do - it 'is true when commit message starts with "Apply suggestion to"' do - expect(commit_linter.suggestion?).to be(is_suggestion) - end - end - end - - describe '#merge?' do - where(:commit_message, :is_merge) do - 'A commit message' | false - 'Merge branch' | true - 'Merge branch "A commit message"' | true - end - - with_them do - it 'is true when commit message starts with "Merge branch"' do - expect(commit_linter.merge?).to be(is_merge) - end - end - end - - describe '#revert?' do - where(:commit_message, :is_revert) do - 'A commit message' | false - 'Revert' | false - 'Revert "' | true - 'Revert "A commit message"' | true - end - - with_them do - it 'is true when commit message starts with "Revert \""' do - expect(commit_linter.revert?).to be(is_revert) - end - end - end - - describe '#multi_line?' do - where(:commit_message, :is_multi_line) do - "A commit message" | false - "A commit message\n" | false - "A commit message\n\n" | false - "A commit message\n\nSigned-off-by: User Name <user@name.me>" | false - "A commit message\n\nWith details" | true - end - - with_them do - it 'is true when commit message contains details' do - expect(commit_linter.multi_line?).to be(is_multi_line) - end - end - end - - shared_examples 'a valid commit' do - it 'does not have any problem' do - commit_linter.lint - - expect(commit_linter.problems).to be_empty - end - end - - describe '#lint' do - describe 'separator' do - context 'when separator is missing' do - let(:commit_message) { "A B C\n" } - - it_behaves_like 'a valid commit' - end - - context 'when separator is a blank line' do - let(:commit_message) { "A B C\n\nMore details." } - - it_behaves_like 'a valid commit' - end - - context 'when separator is missing' do - let(:commit_message) { "A B C\nMore details." } - - it 'adds a problem' do - expect(commit_linter).to receive(:add_problem).with(:separator_missing) - - commit_linter.lint - end - end - end - - describe 'details' do - context 'when details are valid' do - let(:commit_message) { "A B C\n\nMore details." } - - it_behaves_like 'a valid commit' - end - - context 'when no details are given and many files are changed' do - let(:total_files_changed) { described_class::MAX_CHANGED_FILES_IN_COMMIT + 1 } - - it_behaves_like 'a valid commit' - end - - context 'when no details are given and many lines are changed' do - let(:total_lines_changed) { described_class::MAX_CHANGED_LINES_IN_COMMIT + 1 } - - it_behaves_like 'a valid commit' - end - - context 'when no details are given and many files and lines are changed' do - let(:total_files_changed) { described_class::MAX_CHANGED_FILES_IN_COMMIT + 1 } - let(:total_lines_changed) { described_class::MAX_CHANGED_LINES_IN_COMMIT + 1 } - - it 'adds a problem' do - expect(commit_linter).to receive(:add_problem).with(:details_too_many_changes) - - commit_linter.lint - end - end - - context 'when details exceeds the max line length' do - let(:commit_message) { "A B C\n\n" + 'D' * (described_class::MAX_LINE_LENGTH + 1) } - - it 'adds a problem' do - expect(commit_linter).to receive(:add_problem).with(:details_line_too_long) - - commit_linter.lint - end - end - - context 'when details exceeds the max line length including URLs' do - let(:commit_message) do - "A B C\n\nsome message with https://example.com and https://gitlab.com" + 'D' * described_class::MAX_LINE_LENGTH - end - - it_behaves_like 'a valid commit' - end - end - - describe 'message' do - context 'when message includes a text emoji' do - let(:commit_message) { "A commit message :+1:" } - - it 'adds a problem' do - expect(commit_linter).to receive(:add_problem).with(:message_contains_text_emoji) - - commit_linter.lint - end - end - - context 'when message includes a unicode emoji' do - let(:commit_message) { "A commit message 🚀" } - - it 'adds a problem' do - expect(commit_linter).to receive(:add_problem).with(:message_contains_unicode_emoji) - - commit_linter.lint - end - end - - context 'when message includes a value that is surrounded by backticks' do - let(:commit_message) { "A commit message `%20`" } - - it 'does not add a problem' do - expect(commit_linter).not_to receive(:add_problem) - - commit_linter.lint - end - end - - context 'when message includes a short reference' do - [ - 'A commit message to fix #1234', - 'A commit message to fix !1234', - 'A commit message to fix &1234', - 'A commit message to fix %1234', - 'A commit message to fix gitlab#1234', - 'A commit message to fix gitlab!1234', - 'A commit message to fix gitlab&1234', - 'A commit message to fix gitlab%1234', - 'A commit message to fix gitlab-org/gitlab#1234', - 'A commit message to fix gitlab-org/gitlab!1234', - 'A commit message to fix gitlab-org/gitlab&1234', - 'A commit message to fix gitlab-org/gitlab%1234', - 'A commit message to fix "gitlab-org/gitlab%1234"', - 'A commit message to fix `gitlab-org/gitlab%1234' - ].each do |message| - let(:commit_message) { message } - - it 'adds a problem' do - expect(commit_linter).to receive(:add_problem).with(:message_contains_short_reference) - - commit_linter.lint - end - end - end - end - end -end diff --git a/spec/tooling/danger/danger_spec_helper.rb b/spec/tooling/danger/danger_spec_helper.rb deleted file mode 100644 index b1e84b3c13d..00000000000 --- a/spec/tooling/danger/danger_spec_helper.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module DangerSpecHelper - def new_fake_danger - Class.new do - attr_reader :git, :gitlab, :helper - - # rubocop:disable Gitlab/ModuleWithInstanceVariables - def initialize(git: nil, gitlab: nil, helper: nil) - @git = git - @gitlab = gitlab - @helper = helper - end - # rubocop:enable Gitlab/ModuleWithInstanceVariables - end - end -end diff --git a/spec/tooling/danger/emoji_checker_spec.rb b/spec/tooling/danger/emoji_checker_spec.rb deleted file mode 100644 index bbd957b3d00..00000000000 --- a/spec/tooling/danger/emoji_checker_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require 'rspec-parameterized' - -require_relative '../../../tooling/danger/emoji_checker' - -RSpec.describe Tooling::Danger::EmojiChecker do - using RSpec::Parameterized::TableSyntax - - describe '#includes_text_emoji?' do - where(:text, :includes_emoji) do - 'Hello World!' | false - ':+1:' | true - 'Hello World! :+1:' | true - end - - with_them do - it 'is true when text includes a text emoji' do - expect(subject.includes_text_emoji?(text)).to be(includes_emoji) - end - end - end - - describe '#includes_unicode_emoji?' do - where(:text, :includes_emoji) do - 'Hello World!' | false - '🚀' | true - 'Hello World! 🚀' | true - end - - with_them do - it 'is true when text includes a text emoji' do - expect(subject.includes_unicode_emoji?(text)).to be(includes_emoji) - end - end - end -end diff --git a/spec/tooling/danger/feature_flag_spec.rb b/spec/tooling/danger/feature_flag_spec.rb index db63116cc37..5e495cd43c6 100644 --- a/spec/tooling/danger/feature_flag_spec.rb +++ b/spec/tooling/danger/feature_flag_spec.rb @@ -1,29 +1,16 @@ # frozen_string_literal: true -require_relative 'danger_spec_helper' +require 'gitlab-dangerfiles' +require 'gitlab/dangerfiles/spec_helper' require_relative '../../../tooling/danger/feature_flag' RSpec.describe Tooling::Danger::FeatureFlag do - include DangerSpecHelper + include_context "with dangerfile" - let(:added_files) { nil } - let(:modified_files) { nil } - let(:deleted_files) { nil } - let(:fake_git) { double('fake-git', added_files: added_files, modified_files: modified_files, deleted_files: deleted_files) } + let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) } - let(:mr_labels) { nil } - let(:mr_json) { nil } - let(:fake_gitlab) { double('fake-gitlab', mr_labels: mr_labels, mr_json: mr_json) } - - let(:changes_by_category) { nil } - let(:sanitize_mr_title) { nil } - let(:ee?) { false } - let(:fake_helper) { double('fake-helper', changes_by_category: changes_by_category, sanitize_mr_title: sanitize_mr_title, ee?: ee?) } - - let(:fake_danger) { new_fake_danger.include(described_class) } - - subject(:feature_flag) { fake_danger.new(git: fake_git, gitlab: fake_gitlab, helper: fake_helper) } + subject(:feature_flag) { fake_danger.new(git: fake_git) } describe '#feature_flag_files' do let(:feature_flag_files) do diff --git a/spec/tooling/danger/helper_spec.rb b/spec/tooling/danger/helper_spec.rb deleted file mode 100644 index c338d138352..00000000000 --- a/spec/tooling/danger/helper_spec.rb +++ /dev/null @@ -1,682 +0,0 @@ -# frozen_string_literal: true - -require 'fast_spec_helper' -require 'rspec-parameterized' -require_relative 'danger_spec_helper' - -require_relative '../../../tooling/danger/helper' - -RSpec.describe Tooling::Danger::Helper do - using RSpec::Parameterized::TableSyntax - include DangerSpecHelper - - let(:fake_git) { double('fake-git') } - - let(:mr_author) { nil } - let(:fake_gitlab) { double('fake-gitlab', mr_author: mr_author) } - - let(:fake_danger) { new_fake_danger.include(described_class) } - - subject(:helper) { fake_danger.new(git: fake_git, gitlab: fake_gitlab) } - - describe '#gitlab_helper' do - context 'when gitlab helper is not available' do - let(:fake_gitlab) { nil } - - it 'returns nil' do - expect(helper.gitlab_helper).to be_nil - end - end - - context 'when gitlab helper is available' do - it 'returns the gitlab helper' do - expect(helper.gitlab_helper).to eq(fake_gitlab) - end - end - - context 'when danger gitlab plugin is not available' do - it 'returns nil' do - invalid_danger = Class.new do - include Tooling::Danger::Helper - end.new - - expect(invalid_danger.gitlab_helper).to be_nil - end - end - end - - describe '#release_automation?' do - context 'when gitlab helper is not available' do - it 'returns false' do - expect(helper.release_automation?).to be_falsey - end - end - - context 'when gitlab helper is available' do - context "but the MR author isn't the RELEASE_TOOLS_BOT" do - let(:mr_author) { 'johnmarston' } - - it 'returns false' do - expect(helper.release_automation?).to be_falsey - end - end - - context 'and the MR author is the RELEASE_TOOLS_BOT' do - let(:mr_author) { described_class::RELEASE_TOOLS_BOT } - - it 'returns true' do - expect(helper.release_automation?).to be_truthy - end - end - end - end - - describe '#all_changed_files' do - subject { helper.all_changed_files } - - it 'interprets a list of changes from the danger git plugin' do - expect(fake_git).to receive(:added_files) { %w[a b c.old] } - expect(fake_git).to receive(:modified_files) { %w[d e] } - expect(fake_git) - .to receive(:renamed_files) - .at_least(:once) - .and_return([{ before: 'c.old', after: 'c.new' }]) - - is_expected.to contain_exactly('a', 'b', 'c.new', 'd', 'e') - end - end - - describe '#changed_lines' do - subject { helper.changed_lines('changed_file.rb') } - - before do - allow(fake_git).to receive(:diff_for_file).with('changed_file.rb').and_return(diff) - end - - context 'when file has diff' do - let(:diff) { double(:diff, patch: "+ # New change here\n+ # New change there") } - - it 'returns file changes' do - is_expected.to eq(['+ # New change here', '+ # New change there']) - end - end - - context 'when file has no diff (renamed without changes)' do - let(:diff) { nil } - - it 'returns a blank array' do - is_expected.to eq([]) - end - end - end - - describe "changed_files" do - it 'returns list of changed files matching given regex' do - expect(helper).to receive(:all_changed_files).and_return(%w[migration.rb usage_data.rb]) - - expect(helper.changed_files(/usage_data/)).to contain_exactly('usage_data.rb') - end - end - - describe '#all_ee_changes' do - subject { helper.all_ee_changes } - - it 'returns all changed files starting with ee/' do - expect(helper).to receive(:all_changed_files).and_return(%w[fr/ee/beer.rb ee/wine.rb ee/lib/ido.rb ee.k]) - - is_expected.to match_array(%w[ee/wine.rb ee/lib/ido.rb]) - end - end - - describe '#ee?' do - subject { helper.ee? } - - it 'returns true if CI_PROJECT_NAME if set to gitlab' do - stub_env('CI_PROJECT_NAME', 'gitlab') - expect(Dir).not_to receive(:exist?) - - is_expected.to be_truthy - end - - it 'delegates to CHANGELOG-EE.md existence if CI_PROJECT_NAME is set to something else' do - stub_env('CI_PROJECT_NAME', 'something else') - expect(Dir).to receive(:exist?).with(File.expand_path('../../../../ee', __dir__)) { true } - - is_expected.to be_truthy - end - - it 'returns true if ee exists' do - stub_env('CI_PROJECT_NAME', nil) - expect(Dir).to receive(:exist?).with(File.expand_path('../../../../ee', __dir__)) { true } - - is_expected.to be_truthy - end - - it "returns false if ee doesn't exist" do - stub_env('CI_PROJECT_NAME', nil) - expect(Dir).to receive(:exist?).with(File.expand_path('../../../../ee', __dir__)) { false } - - is_expected.to be_falsy - end - end - - describe '#project_name' do - subject { helper.project_name } - - it 'returns gitlab if ee? returns true' do - expect(helper).to receive(:ee?) { true } - - is_expected.to eq('gitlab') - end - - it 'returns gitlab-ce if ee? returns false' do - expect(helper).to receive(:ee?) { false } - - is_expected.to eq('gitlab-foss') - end - end - - describe '#markdown_list' do - it 'creates a markdown list of items' do - items = %w[a b] - - expect(helper.markdown_list(items)).to eq("* `a`\n* `b`") - end - - it 'wraps items in <details> when there are more than 10 items' do - items = ('a'..'k').to_a - - expect(helper.markdown_list(items)).to match(%r{<details>[^<]+</details>}) - end - end - - describe '#changes_by_category' do - it 'categorizes changed files' do - expect(fake_git).to receive(:added_files) { %w[foo foo.md foo.rb foo.js db/migrate/foo lib/gitlab/database/foo.rb qa/foo ee/changelogs/foo.yml] } - allow(fake_git).to receive(:modified_files) { [] } - allow(fake_git).to receive(:renamed_files) { [] } - - expect(helper.changes_by_category).to eq( - backend: %w[foo.rb], - database: %w[db/migrate/foo lib/gitlab/database/foo.rb], - frontend: %w[foo.js], - none: %w[ee/changelogs/foo.yml foo.md], - qa: %w[qa/foo], - unknown: %w[foo] - ) - end - end - - describe '#categories_for_file' do - before do - allow(fake_git).to receive(:diff_for_file).with('usage_data.rb') { double(:diff, patch: "+ count(User.active)") } - end - - where(:path, :expected_categories) do - 'usage_data.rb' | [:database, :backend] - 'doc/foo.md' | [:docs] - 'CONTRIBUTING.md' | [:docs] - 'LICENSE' | [:docs] - 'MAINTENANCE.md' | [:docs] - 'PHILOSOPHY.md' | [:docs] - 'PROCESS.md' | [:docs] - 'README.md' | [:docs] - - 'ee/doc/foo' | [:unknown] - 'ee/README' | [:unknown] - - 'app/assets/foo' | [:frontend] - 'app/views/foo' | [:frontend] - 'public/foo' | [:frontend] - 'scripts/frontend/foo' | [:frontend] - 'spec/javascripts/foo' | [:frontend] - 'spec/frontend/bar' | [:frontend] - 'vendor/assets/foo' | [:frontend] - 'babel.config.js' | [:frontend] - 'jest.config.js' | [:frontend] - 'package.json' | [:frontend] - 'yarn.lock' | [:frontend] - 'config/foo.js' | [:frontend] - 'config/deep/foo.js' | [:frontend] - - 'ee/app/assets/foo' | [:frontend] - 'ee/app/views/foo' | [:frontend] - 'ee/spec/javascripts/foo' | [:frontend] - 'ee/spec/frontend/bar' | [:frontend] - - '.gitlab/ci/frontend.gitlab-ci.yml' | %i[frontend engineering_productivity] - - 'app/models/foo' | [:backend] - 'bin/foo' | [:backend] - 'config/foo' | [:backend] - 'lib/foo' | [:backend] - 'rubocop/foo' | [:backend] - '.rubocop.yml' | [:backend] - '.rubocop_todo.yml' | [:backend] - '.rubocop_manual_todo.yml' | [:backend] - 'spec/foo' | [:backend] - 'spec/foo/bar' | [:backend] - - 'ee/app/foo' | [:backend] - 'ee/bin/foo' | [:backend] - 'ee/spec/foo' | [:backend] - 'ee/spec/foo/bar' | [:backend] - - 'spec/features/foo' | [:test] - 'ee/spec/features/foo' | [:test] - 'spec/support/shared_examples/features/foo' | [:test] - 'ee/spec/support/shared_examples/features/foo' | [:test] - 'spec/support/shared_contexts/features/foo' | [:test] - 'ee/spec/support/shared_contexts/features/foo' | [:test] - 'spec/support/helpers/features/foo' | [:test] - 'ee/spec/support/helpers/features/foo' | [:test] - - 'generator_templates/foo' | [:backend] - 'vendor/languages.yml' | [:backend] - 'file_hooks/examples/' | [:backend] - - 'Gemfile' | [:backend] - 'Gemfile.lock' | [:backend] - 'Rakefile' | [:backend] - 'FOO_VERSION' | [:backend] - - 'Dangerfile' | [:engineering_productivity] - 'danger/commit_messages/Dangerfile' | [:engineering_productivity] - 'ee/danger/commit_messages/Dangerfile' | [:engineering_productivity] - 'danger/commit_messages/' | [:engineering_productivity] - 'ee/danger/commit_messages/' | [:engineering_productivity] - '.gitlab-ci.yml' | [:engineering_productivity] - '.gitlab/ci/cng.gitlab-ci.yml' | [:engineering_productivity] - '.gitlab/ci/ee-specific-checks.gitlab-ci.yml' | [:engineering_productivity] - 'scripts/foo' | [:engineering_productivity] - 'tooling/danger/foo' | [:engineering_productivity] - 'ee/tooling/danger/foo' | [:engineering_productivity] - 'lefthook.yml' | [:engineering_productivity] - '.editorconfig' | [:engineering_productivity] - 'tooling/bin/find_foss_tests' | [:engineering_productivity] - '.codeclimate.yml' | [:engineering_productivity] - '.gitlab/CODEOWNERS' | [:engineering_productivity] - - 'lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml' | [:ci_template] - 'lib/gitlab/ci/templates/dotNET-Core.yml' | [:ci_template] - - 'ee/FOO_VERSION' | [:unknown] - - 'db/schema.rb' | [:database] - 'db/structure.sql' | [:database] - 'db/migrate/foo' | [:database] - 'db/post_migrate/foo' | [:database] - 'ee/db/migrate/foo' | [:database] - 'ee/db/post_migrate/foo' | [:database] - 'ee/db/geo/migrate/foo' | [:database] - 'ee/db/geo/post_migrate/foo' | [:database] - 'app/models/project_authorization.rb' | [:database] - 'app/services/users/refresh_authorized_projects_service.rb' | [:database] - 'lib/gitlab/background_migration.rb' | [:database] - 'lib/gitlab/background_migration/foo' | [:database] - 'ee/lib/gitlab/background_migration/foo' | [:database] - 'lib/gitlab/database.rb' | [:database] - 'lib/gitlab/database/foo' | [:database] - 'ee/lib/gitlab/database/foo' | [:database] - 'lib/gitlab/github_import.rb' | [:database] - 'lib/gitlab/github_import/foo' | [:database] - 'lib/gitlab/sql/foo' | [:database] - 'rubocop/cop/migration/foo' | [:database] - - 'db/fixtures/foo.rb' | [:backend] - 'ee/db/fixtures/foo.rb' | [:backend] - 'doc/api/graphql/reference/gitlab_schema.graphql' | [:backend] - 'doc/api/graphql/reference/gitlab_schema.json' | [:backend] - - 'qa/foo' | [:qa] - 'ee/qa/foo' | [:qa] - - 'changelogs/foo' | [:none] - 'ee/changelogs/foo' | [:none] - 'locale/gitlab.pot' | [:none] - - 'FOO' | [:unknown] - 'foo' | [:unknown] - - 'foo/bar.rb' | [:backend] - 'foo/bar.js' | [:frontend] - 'foo/bar.txt' | [:none] - 'foo/bar.md' | [:none] - end - - with_them do - subject { helper.categories_for_file(path) } - - it { is_expected.to eq(expected_categories) } - end - - context 'having specific changes' do - where(:expected_categories, :patch, :changed_files) do - [:database, :backend] | '+ count(User.active)' | ['usage_data.rb', 'lib/gitlab/usage_data.rb', 'ee/lib/ee/gitlab/usage_data.rb'] - [:database, :backend] | '+ estimate_batch_distinct_count(User.active)' | ['usage_data.rb'] - [:backend] | '+ alt_usage_data(User.active)' | ['usage_data.rb'] - [:backend] | '+ count(User.active)' | ['user.rb'] - [:backend] | '+ count(User.active)' | ['usage_data/topology.rb'] - [:backend] | '+ foo_count(User.active)' | ['usage_data.rb'] - end - - with_them do - it 'has the correct categories' do - changed_files.each do |file| - allow(fake_git).to receive(:diff_for_file).with(file) { double(:diff, patch: patch) } - - expect(helper.categories_for_file(file)).to eq(expected_categories) - end - end - end - end - end - - describe '#label_for_category' do - where(:category, :expected_label) do - :backend | '~backend' - :database | '~database' - :docs | '~documentation' - :foo | '~foo' - :frontend | '~frontend' - :none | '' - :qa | '~QA' - :engineering_productivity | '~"Engineering Productivity" for CI, Danger' - :ci_template | '~"ci::templates"' - end - - with_them do - subject { helper.label_for_category(category) } - - it { is_expected.to eq(expected_label) } - end - end - - describe '#new_teammates' do - it 'returns an array of Teammate' do - usernames = %w[filipa iamphil] - - teammates = helper.new_teammates(usernames) - - expect(teammates.map(&:username)).to eq(usernames) - end - end - - describe '#mr_title' do - it 'returns "" when `gitlab_helper` is unavailable' do - expect(helper).to receive(:gitlab_helper).and_return(nil) - - expect(helper.mr_title).to eq('') - end - - it 'returns the MR title when `gitlab_helper` is available' do - mr_title = 'My MR title' - expect(fake_gitlab).to receive(:mr_json) - .and_return('title' => mr_title) - - expect(helper.mr_title).to eq(mr_title) - end - end - - describe '#mr_web_url' do - it 'returns "" when `gitlab_helper` is unavailable' do - expect(helper).to receive(:gitlab_helper).and_return(nil) - - expect(helper.mr_web_url).to eq('') - end - - it 'returns the MR web_url when `gitlab_helper` is available' do - mr_web_url = 'https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1' - expect(fake_gitlab).to receive(:mr_json) - .and_return('web_url' => mr_web_url) - - expect(helper.mr_web_url).to eq(mr_web_url) - end - end - - describe '#mr_target_branch' do - it 'returns "" when `gitlab_helper` is unavailable' do - expect(helper).to receive(:gitlab_helper).and_return(nil) - - expect(helper.mr_target_branch).to eq('') - end - - it 'returns the MR web_url when `gitlab_helper` is available' do - mr_target_branch = 'main' - expect(fake_gitlab).to receive(:mr_json) - .and_return('target_branch' => mr_target_branch) - - expect(helper.mr_target_branch).to eq(mr_target_branch) - end - end - - describe '#security_mr?' do - it 'returns false when on a normal merge request' do - expect(fake_gitlab).to receive(:mr_json) - .and_return('web_url' => 'https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1') - - expect(helper).not_to be_security_mr - end - - it 'returns true when on a security merge request' do - expect(fake_gitlab).to receive(:mr_json) - .and_return('web_url' => 'https://gitlab.com/gitlab-org/security/gitlab/-/merge_requests/1') - - expect(helper).to be_security_mr - end - end - - describe '#draft_mr?' do - it 'returns true for a draft MR' do - expect(fake_gitlab).to receive(:mr_json) - .and_return('title' => 'Draft: My MR title') - - expect(helper).to be_draft_mr - end - - it 'returns false for non draft MR' do - expect(fake_gitlab).to receive(:mr_json) - .and_return('title' => 'My MR title') - - expect(helper).not_to be_draft_mr - end - end - - describe '#cherry_pick_mr?' do - context 'when MR title does not mention a cherry-pick' do - it 'returns false' do - expect(fake_gitlab).to receive(:mr_json) - .and_return('title' => 'Add feature xyz') - - expect(helper).not_to be_cherry_pick_mr - end - end - - context 'when MR title mentions a cherry-pick' do - [ - 'Cherry Pick !1234', - 'cherry-pick !1234', - 'CherryPick !1234' - ].each do |mr_title| - it 'returns true' do - expect(fake_gitlab).to receive(:mr_json) - .and_return('title' => mr_title) - - expect(helper).to be_cherry_pick_mr - end - end - end - end - - describe '#run_all_rspec_mr?' do - context 'when MR title does not mention RUN ALL RSPEC' do - it 'returns false' do - expect(fake_gitlab).to receive(:mr_json) - .and_return('title' => 'Add feature xyz') - - expect(helper).not_to be_run_all_rspec_mr - end - end - - context 'when MR title mentions RUN ALL RSPEC' do - it 'returns true' do - expect(fake_gitlab).to receive(:mr_json) - .and_return('title' => 'Add feature xyz RUN ALL RSPEC') - - expect(helper).to be_run_all_rspec_mr - end - end - end - - describe '#run_as_if_foss_mr?' do - context 'when MR title does not mention RUN AS-IF-FOSS' do - it 'returns false' do - expect(fake_gitlab).to receive(:mr_json) - .and_return('title' => 'Add feature xyz') - - expect(helper).not_to be_run_as_if_foss_mr - end - end - - context 'when MR title mentions RUN AS-IF-FOSS' do - it 'returns true' do - expect(fake_gitlab).to receive(:mr_json) - .and_return('title' => 'Add feature xyz RUN AS-IF-FOSS') - - expect(helper).to be_run_as_if_foss_mr - end - end - end - - describe '#stable_branch?' do - it 'returns false when `gitlab_helper` is unavailable' do - expect(helper).to receive(:gitlab_helper).and_return(nil) - - expect(helper).not_to be_stable_branch - end - - context 'when MR target branch is not a stable branch' do - it 'returns false' do - expect(fake_gitlab).to receive(:mr_json) - .and_return('target_branch' => 'my-feature-branch') - - expect(helper).not_to be_stable_branch - end - end - - context 'when MR target branch is a stable branch' do - %w[ - 13-1-stable-ee - 13-1-stable-ee-patch-1 - ].each do |target_branch| - it 'returns true' do - expect(fake_gitlab).to receive(:mr_json) - .and_return('target_branch' => target_branch) - - expect(helper).to be_stable_branch - end - end - end - end - - describe '#mr_has_label?' do - it 'returns false when `gitlab_helper` is unavailable' do - expect(helper).to receive(:gitlab_helper).and_return(nil) - - expect(helper.mr_has_labels?('telemetry')).to be_falsey - end - - context 'when mr has labels' do - before do - mr_labels = ['telemetry', 'telemetry::reviewed'] - expect(fake_gitlab).to receive(:mr_labels).and_return(mr_labels) - end - - it 'returns true with a matched label' do - expect(helper.mr_has_labels?('telemetry')).to be_truthy - end - - it 'returns false with unmatched label' do - expect(helper.mr_has_labels?('database')).to be_falsey - end - - it 'returns true with an array of labels' do - expect(helper.mr_has_labels?(['telemetry', 'telemetry::reviewed'])).to be_truthy - end - - it 'returns true with multi arguments with matched labels' do - expect(helper.mr_has_labels?('telemetry', 'telemetry::reviewed')).to be_truthy - end - - it 'returns false with multi arguments with unmatched labels' do - expect(helper.mr_has_labels?('telemetry', 'telemetry::non existing')).to be_falsey - end - end - end - - describe '#labels_list' do - let(:labels) { ['telemetry', 'telemetry::reviewed'] } - - it 'composes the labels string' do - expect(helper.labels_list(labels)).to eq('~"telemetry", ~"telemetry::reviewed"') - end - - context 'when passing a separator' do - it 'composes the labels string with the given separator' do - expect(helper.labels_list(labels, sep: ' ')).to eq('~"telemetry" ~"telemetry::reviewed"') - end - end - - it 'returns empty string for empty array' do - expect(helper.labels_list([])).to eq('') - end - end - - describe '#prepare_labels_for_mr' do - it 'composes the labels string' do - mr_labels = ['telemetry', 'telemetry::reviewed'] - - expect(helper.prepare_labels_for_mr(mr_labels)).to eq('/label ~"telemetry" ~"telemetry::reviewed"') - end - - it 'returns empty string for empty array' do - expect(helper.prepare_labels_for_mr([])).to eq('') - end - end - - describe '#has_ci_changes?' do - context 'when .gitlab/ci is changed' do - it 'returns true' do - expect(helper).to receive(:all_changed_files).and_return(%w[migration.rb .gitlab/ci/test.yml]) - - expect(helper.has_ci_changes?).to be_truthy - end - end - - context 'when .gitlab-ci.yml is changed' do - it 'returns true' do - expect(helper).to receive(:all_changed_files).and_return(%w[migration.rb .gitlab-ci.yml]) - - expect(helper.has_ci_changes?).to be_truthy - end - end - - context 'when neither .gitlab/ci/ or .gitlab-ci.yml is changed' do - it 'returns false' do - expect(helper).to receive(:all_changed_files).and_return(%w[migration.rb nested/.gitlab-ci.yml]) - - expect(helper.has_ci_changes?).to be_falsey - end - end - end - - describe '#group_label' do - it 'returns nil when no group label is present' do - expect(helper.group_label(%w[foo bar])).to be_nil - end - - it 'returns the group label when a group label is present' do - expect(helper.group_label(['foo', 'group::source code', 'bar'])).to eq('group::source code') - end - end -end diff --git a/spec/tooling/danger/merge_request_linter_spec.rb b/spec/tooling/danger/merge_request_linter_spec.rb deleted file mode 100644 index 3273b6b3d07..00000000000 --- a/spec/tooling/danger/merge_request_linter_spec.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -require 'rspec-parameterized' -require_relative 'danger_spec_helper' - -require_relative '../../../tooling/danger/merge_request_linter' - -RSpec.describe Tooling::Danger::MergeRequestLinter do - using RSpec::Parameterized::TableSyntax - - let(:mr_class) do - Struct.new(:message, :sha, :diff_parent) - end - - let(:mr_title) { 'A B ' + 'C' } - let(:merge_request) { mr_class.new(mr_title, anything, anything) } - - describe '#lint_subject' do - subject(:mr_linter) { described_class.new(merge_request) } - - shared_examples 'a valid mr title' do - it 'does not have any problem' do - mr_linter.lint - - expect(mr_linter.problems).to be_empty - end - end - - context 'when subject valid' do - it_behaves_like 'a valid mr title' - end - - context 'when it is too long' do - let(:mr_title) { 'A B ' + 'C' * described_class::MAX_LINE_LENGTH } - - it 'adds a problem' do - expect(mr_linter).to receive(:add_problem).with(:subject_too_long, described_class.subject_description) - - mr_linter.lint - end - end - - describe 'using magic mr run options' do - where(run_option: described_class.mr_run_options_regex.split('|') + - described_class.mr_run_options_regex.split('|').map! { |x| "[#{x}]" }) - - with_them do - let(:mr_title) { run_option + ' A B ' + 'C' * (described_class::MAX_LINE_LENGTH - 5) } - - it_behaves_like 'a valid mr title' - end - end - end -end diff --git a/spec/tooling/danger/project_helper_spec.rb b/spec/tooling/danger/project_helper_spec.rb new file mode 100644 index 00000000000..a8fda901b4a --- /dev/null +++ b/spec/tooling/danger/project_helper_spec.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +require 'rspec-parameterized' +require 'gitlab-dangerfiles' +require 'danger/helper' +require 'gitlab/dangerfiles/spec_helper' + +require_relative '../../../danger/plugins/project_helper' + +RSpec.describe Tooling::Danger::ProjectHelper do + include_context "with dangerfile" + + let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) } + let(:fake_helper) { Danger::Helper.new(project_helper) } + + subject(:project_helper) { fake_danger.new(git: fake_git) } + + before do + allow(project_helper).to receive(:helper).and_return(fake_helper) + end + + describe '#changes' do + it 'returns an array of Change objects' do + expect(project_helper.changes).to all(be_an(Gitlab::Dangerfiles::Change)) + end + + it 'groups changes by change type' do + changes = project_helper.changes + + expect(changes.added.files).to eq(added_files) + expect(changes.modified.files).to eq(modified_files) + expect(changes.deleted.files).to eq(deleted_files) + expect(changes.renamed_before.files).to eq([renamed_before_file]) + expect(changes.renamed_after.files).to eq([renamed_after_file]) + end + end + + describe '#categories_for_file' do + using RSpec::Parameterized::TableSyntax + + before do + allow(fake_git).to receive(:diff_for_file).with('usage_data.rb') { double(:diff, patch: "+ count(User.active)") } + end + + where(:path, :expected_categories) do + 'usage_data.rb' | [:database, :backend] + 'doc/foo.md' | [:docs] + 'CONTRIBUTING.md' | [:docs] + 'LICENSE' | [:docs] + 'MAINTENANCE.md' | [:docs] + 'PHILOSOPHY.md' | [:docs] + 'PROCESS.md' | [:docs] + 'README.md' | [:docs] + + 'ee/doc/foo' | [:unknown] + 'ee/README' | [:unknown] + + 'app/assets/foo' | [:frontend] + 'app/views/foo' | [:frontend] + 'public/foo' | [:frontend] + 'scripts/frontend/foo' | [:frontend] + 'spec/javascripts/foo' | [:frontend] + 'spec/frontend/bar' | [:frontend] + 'vendor/assets/foo' | [:frontend] + 'babel.config.js' | [:frontend] + 'jest.config.js' | [:frontend] + 'package.json' | [:frontend] + 'yarn.lock' | [:frontend] + 'config/foo.js' | [:frontend] + 'config/deep/foo.js' | [:frontend] + + 'ee/app/assets/foo' | [:frontend] + 'ee/app/views/foo' | [:frontend] + 'ee/spec/javascripts/foo' | [:frontend] + 'ee/spec/frontend/bar' | [:frontend] + + '.gitlab/ci/frontend.gitlab-ci.yml' | %i[frontend engineering_productivity] + + 'app/models/foo' | [:backend] + 'bin/foo' | [:backend] + 'config/foo' | [:backend] + 'lib/foo' | [:backend] + 'rubocop/foo' | [:backend] + '.rubocop.yml' | [:backend] + '.rubocop_todo.yml' | [:backend] + '.rubocop_manual_todo.yml' | [:backend] + 'spec/foo' | [:backend] + 'spec/foo/bar' | [:backend] + + 'ee/app/foo' | [:backend] + 'ee/bin/foo' | [:backend] + 'ee/spec/foo' | [:backend] + 'ee/spec/foo/bar' | [:backend] + + 'spec/features/foo' | [:test] + 'ee/spec/features/foo' | [:test] + 'spec/support/shared_examples/features/foo' | [:test] + 'ee/spec/support/shared_examples/features/foo' | [:test] + 'spec/support/shared_contexts/features/foo' | [:test] + 'ee/spec/support/shared_contexts/features/foo' | [:test] + 'spec/support/helpers/features/foo' | [:test] + 'ee/spec/support/helpers/features/foo' | [:test] + + 'generator_templates/foo' | [:backend] + 'vendor/languages.yml' | [:backend] + 'file_hooks/examples/' | [:backend] + + 'Gemfile' | [:backend] + 'Gemfile.lock' | [:backend] + 'Rakefile' | [:backend] + 'FOO_VERSION' | [:backend] + + 'Dangerfile' | [:engineering_productivity] + 'danger/commit_messages/Dangerfile' | [:engineering_productivity] + 'ee/danger/commit_messages/Dangerfile' | [:engineering_productivity] + 'danger/commit_messages/' | [:engineering_productivity] + 'ee/danger/commit_messages/' | [:engineering_productivity] + '.gitlab-ci.yml' | [:engineering_productivity] + '.gitlab/ci/cng.gitlab-ci.yml' | [:engineering_productivity] + '.gitlab/ci/ee-specific-checks.gitlab-ci.yml' | [:engineering_productivity] + 'scripts/foo' | [:engineering_productivity] + 'tooling/danger/foo' | [:engineering_productivity] + 'ee/tooling/danger/foo' | [:engineering_productivity] + 'lefthook.yml' | [:engineering_productivity] + '.editorconfig' | [:engineering_productivity] + 'tooling/bin/find_foss_tests' | [:engineering_productivity] + '.codeclimate.yml' | [:engineering_productivity] + '.gitlab/CODEOWNERS' | [:engineering_productivity] + + 'lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml' | [:ci_template] + 'lib/gitlab/ci/templates/dotNET-Core.yml' | [:ci_template] + + 'ee/FOO_VERSION' | [:unknown] + + 'db/schema.rb' | [:database] + 'db/structure.sql' | [:database] + 'db/migrate/foo' | [:database, :migration] + 'db/post_migrate/foo' | [:database, :migration] + 'ee/db/geo/migrate/foo' | [:database, :migration] + 'ee/db/geo/post_migrate/foo' | [:database, :migration] + 'app/models/project_authorization.rb' | [:database] + 'app/services/users/refresh_authorized_projects_service.rb' | [:database] + 'lib/gitlab/background_migration.rb' | [:database] + 'lib/gitlab/background_migration/foo' | [:database] + 'ee/lib/gitlab/background_migration/foo' | [:database] + 'lib/gitlab/database.rb' | [:database] + 'lib/gitlab/database/foo' | [:database] + 'ee/lib/gitlab/database/foo' | [:database] + 'lib/gitlab/github_import.rb' | [:database] + 'lib/gitlab/github_import/foo' | [:database] + 'lib/gitlab/sql/foo' | [:database] + 'rubocop/cop/migration/foo' | [:database] + + 'db/fixtures/foo.rb' | [:backend] + 'ee/db/fixtures/foo.rb' | [:backend] + + 'qa/foo' | [:qa] + 'ee/qa/foo' | [:qa] + + 'changelogs/foo' | [:none] + 'ee/changelogs/foo' | [:none] + 'locale/gitlab.pot' | [:none] + + 'FOO' | [:unknown] + 'foo' | [:unknown] + + 'foo/bar.rb' | [:backend] + 'foo/bar.js' | [:frontend] + 'foo/bar.txt' | [:none] + 'foo/bar.md' | [:none] + end + + with_them do + subject { project_helper.categories_for_file(path) } + + it { is_expected.to eq(expected_categories) } + end + + context 'having specific changes' do + where(:expected_categories, :patch, :changed_files) do + [:database, :backend] | '+ count(User.active)' | ['usage_data.rb', 'lib/gitlab/usage_data.rb', 'ee/lib/ee/gitlab/usage_data.rb'] + [:database, :backend] | '+ estimate_batch_distinct_count(User.active)' | ['usage_data.rb'] + [:backend] | '+ alt_usage_data(User.active)' | ['usage_data.rb'] + [:backend] | '+ count(User.active)' | ['user.rb'] + [:backend] | '+ count(User.active)' | ['usage_data/topology.rb'] + [:backend] | '+ foo_count(User.active)' | ['usage_data.rb'] + end + + with_them do + it 'has the correct categories' do + changed_files.each do |file| + allow(fake_git).to receive(:diff_for_file).with(file) { double(:diff, patch: patch) } + + expect(project_helper.categories_for_file(file)).to eq(expected_categories) + end + end + end + end + end + + describe '.local_warning_message' do + it 'returns an informational message with rules that can run' do + expect(described_class.local_warning_message).to eq('==> Only the following Danger rules can be run locally: changes_size, commit_messages, database, documentation, duplicate_yarn_dependencies, eslint, karma, pajamas, pipeline, prettier, product_intelligence, utility_css') + end + end + + describe '.success_message' do + it 'returns an informational success message' do + expect(described_class.success_message).to eq('==> No Danger rule violations!') + end + end + + describe '#rule_names' do + context 'when running locally' do + before do + expect(fake_helper).to receive(:ci?).and_return(false) + end + + it 'returns local only rules' do + expect(project_helper.rule_names).to match_array(described_class::LOCAL_RULES) + end + end + + context 'when running under CI' do + before do + expect(fake_helper).to receive(:ci?).and_return(true) + end + + it 'returns all rules' do + expect(project_helper.rule_names).to eq(described_class::LOCAL_RULES | described_class::CI_ONLY_RULES) + end + end + end + + describe '#all_ee_changes' do + subject { project_helper.all_ee_changes } + + it 'returns all changed files starting with ee/' do + expect(fake_helper).to receive(:all_changed_files).and_return(%w[fr/ee/beer.rb ee/wine.rb ee/lib/ido.rb ee.k]) + + is_expected.to match_array(%w[ee/wine.rb ee/lib/ido.rb]) + end + end + + describe '#project_name' do + subject { project_helper.project_name } + + it 'returns gitlab if ee? returns true' do + expect(project_helper).to receive(:ee?) { true } + + is_expected.to eq('gitlab') + end + + it 'returns gitlab-ce if ee? returns false' do + expect(project_helper).to receive(:ee?) { false } + + is_expected.to eq('gitlab-foss') + end + end +end diff --git a/spec/tooling/danger/roulette_spec.rb b/spec/tooling/danger/roulette_spec.rb deleted file mode 100644 index 1e500a1ed08..00000000000 --- a/spec/tooling/danger/roulette_spec.rb +++ /dev/null @@ -1,429 +0,0 @@ -# frozen_string_literal: true - -require 'webmock/rspec' -require 'timecop' - -require_relative '../../../tooling/danger/roulette' -require 'active_support/testing/time_helpers' - -RSpec.describe Tooling::Danger::Roulette do - include ActiveSupport::Testing::TimeHelpers - - around do |example| - travel_to(Time.utc(2020, 06, 22, 10)) { example.run } - end - - let(:backend_available) { true } - let(:backend_tz_offset_hours) { 2.0 } - let(:backend_maintainer) do - Tooling::Danger::Teammate.new( - 'username' => 'backend-maintainer', - 'name' => 'Backend maintainer', - 'role' => 'Backend engineer', - 'projects' => { 'gitlab' => 'maintainer backend' }, - 'available' => backend_available, - 'tz_offset_hours' => backend_tz_offset_hours - ) - end - - let(:frontend_reviewer) do - Tooling::Danger::Teammate.new( - 'username' => 'frontend-reviewer', - 'name' => 'Frontend reviewer', - 'role' => 'Frontend engineer', - 'projects' => { 'gitlab' => 'reviewer frontend' }, - 'available' => true, - 'tz_offset_hours' => 2.0 - ) - end - - let(:frontend_maintainer) do - Tooling::Danger::Teammate.new( - 'username' => 'frontend-maintainer', - 'name' => 'Frontend maintainer', - 'role' => 'Frontend engineer', - 'projects' => { 'gitlab' => "maintainer frontend" }, - 'available' => true, - 'tz_offset_hours' => 2.0 - ) - end - - let(:software_engineer_in_test) do - Tooling::Danger::Teammate.new( - 'username' => 'software-engineer-in-test', - 'name' => 'Software Engineer in Test', - 'role' => 'Software Engineer in Test, Create:Source Code', - 'projects' => { 'gitlab' => 'maintainer qa', 'gitlab-qa' => 'maintainer' }, - 'available' => true, - 'tz_offset_hours' => 2.0 - ) - end - - let(:engineering_productivity_reviewer) do - Tooling::Danger::Teammate.new( - 'username' => 'eng-prod-reviewer', - 'name' => 'EP engineer', - 'role' => 'Engineering Productivity', - 'projects' => { 'gitlab' => 'reviewer backend' }, - 'available' => true, - 'tz_offset_hours' => 2.0 - ) - end - - let(:ci_template_reviewer) do - Tooling::Danger::Teammate.new( - 'username' => 'ci-template-maintainer', - 'name' => 'CI Template engineer', - 'role' => '~"ci::templates"', - 'projects' => { 'gitlab' => 'reviewer ci_template' }, - 'available' => true, - 'tz_offset_hours' => 2.0 - ) - end - - let(:teammates) do - [ - backend_maintainer.to_h, - frontend_maintainer.to_h, - frontend_reviewer.to_h, - software_engineer_in_test.to_h, - engineering_productivity_reviewer.to_h, - ci_template_reviewer.to_h - ] - end - - let(:teammate_json) do - teammates.to_json - end - - subject(:roulette) { Object.new.extend(described_class) } - - describe 'Spin#==' do - it 'compares Spin attributes' do - spin1 = described_class::Spin.new(:backend, frontend_reviewer, frontend_maintainer, false, false) - spin2 = described_class::Spin.new(:backend, frontend_reviewer, frontend_maintainer, false, false) - spin3 = described_class::Spin.new(:backend, frontend_reviewer, frontend_maintainer, false, true) - spin4 = described_class::Spin.new(:backend, frontend_reviewer, frontend_maintainer, true, false) - spin5 = described_class::Spin.new(:backend, frontend_reviewer, backend_maintainer, false, false) - spin6 = described_class::Spin.new(:backend, backend_maintainer, frontend_maintainer, false, false) - spin7 = described_class::Spin.new(:frontend, frontend_reviewer, frontend_maintainer, false, false) - - expect(spin1).to eq(spin2) - expect(spin1).not_to eq(spin3) - expect(spin1).not_to eq(spin4) - expect(spin1).not_to eq(spin5) - expect(spin1).not_to eq(spin6) - expect(spin1).not_to eq(spin7) - end - end - - describe '#spin' do - let!(:project) { 'gitlab' } - let!(:mr_source_branch) { 'a-branch' } - let!(:mr_labels) { ['backend', 'devops::create'] } - let!(:author) { Tooling::Danger::Teammate.new('username' => 'johndoe') } - let(:timezone_experiment) { false } - let(:spins) do - # Stub the request at the latest time so that we can modify the raw data, e.g. available fields. - WebMock - .stub_request(:get, described_class::ROULETTE_DATA_URL) - .to_return(body: teammate_json) - - subject.spin(project, categories, timezone_experiment: timezone_experiment) - end - - before do - allow(subject).to receive(:mr_author_username).and_return(author.username) - allow(subject).to receive(:mr_labels).and_return(mr_labels) - allow(subject).to receive(:mr_source_branch).and_return(mr_source_branch) - end - - context 'when timezone_experiment == false' do - context 'when change contains backend category' do - let(:categories) { [:backend] } - - it 'assigns backend reviewer and maintainer' do - expect(spins[0].reviewer).to eq(engineering_productivity_reviewer) - expect(spins[0].maintainer).to eq(backend_maintainer) - expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, false)]) - end - - context 'when teammate is not available' do - let(:backend_available) { false } - - it 'assigns backend reviewer and no maintainer' do - expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, nil, false, false)]) - end - end - end - - context 'when change contains frontend category' do - let(:categories) { [:frontend] } - - it 'assigns frontend reviewer and maintainer' do - expect(spins).to eq([described_class::Spin.new(:frontend, frontend_reviewer, frontend_maintainer, false, false)]) - end - end - - context 'when change contains many categories' do - let(:categories) { [:frontend, :test, :qa, :engineering_productivity, :ci_template, :backend] } - - it 'has a deterministic sorting order' do - expect(spins.map(&:category)).to eq categories.sort - end - end - - context 'when change contains QA category' do - let(:categories) { [:qa] } - - it 'assigns QA maintainer' do - expect(spins).to eq([described_class::Spin.new(:qa, nil, software_engineer_in_test, false, false)]) - end - end - - context 'when change contains QA category and another category' do - let(:categories) { [:backend, :qa] } - - it 'assigns QA maintainer' do - expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, false), described_class::Spin.new(:qa, nil, software_engineer_in_test, :maintainer, false)]) - end - - context 'and author is an SET' do - let!(:author) { Tooling::Danger::Teammate.new('username' => software_engineer_in_test.username) } - - it 'assigns QA reviewer' do - expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, false), described_class::Spin.new(:qa, nil, nil, false, false)]) - end - end - end - - context 'when change contains Engineering Productivity category' do - let(:categories) { [:engineering_productivity] } - - it 'assigns Engineering Productivity reviewer and fallback to backend maintainer' do - expect(spins).to eq([described_class::Spin.new(:engineering_productivity, engineering_productivity_reviewer, backend_maintainer, false, false)]) - end - end - - context 'when change contains CI/CD Template category' do - let(:categories) { [:ci_template] } - - it 'assigns CI/CD Template reviewer and fallback to backend maintainer' do - expect(spins).to eq([described_class::Spin.new(:ci_template, ci_template_reviewer, backend_maintainer, false, false)]) - end - end - - context 'when change contains test category' do - let(:categories) { [:test] } - - it 'assigns corresponding SET' do - expect(spins).to eq([described_class::Spin.new(:test, software_engineer_in_test, nil, :maintainer, false)]) - end - end - end - - context 'when timezone_experiment == true' do - let(:timezone_experiment) { true } - - context 'when change contains backend category' do - let(:categories) { [:backend] } - - it 'assigns backend reviewer and maintainer' do - expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, true)]) - end - - context 'when teammate is not in a good timezone' do - let(:backend_tz_offset_hours) { 5.0 } - - it 'assigns backend reviewer and no maintainer' do - expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, nil, false, true)]) - end - end - end - - context 'when change includes a category with timezone disabled' do - let(:categories) { [:backend] } - - before do - stub_const("#{described_class}::INCLUDE_TIMEZONE_FOR_CATEGORY", backend: false) - end - - it 'assigns backend reviewer and maintainer' do - expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, false)]) - end - - context 'when teammate is not in a good timezone' do - let(:backend_tz_offset_hours) { 5.0 } - - it 'assigns backend reviewer and maintainer' do - expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, false)]) - end - end - end - end - end - - RSpec::Matchers.define :match_teammates do |expected| - match do |actual| - expected.each do |expected_person| - actual_person_found = actual.find { |actual_person| actual_person.name == expected_person.username } - - actual_person_found && - actual_person_found.name == expected_person.name && - actual_person_found.role == expected_person.role && - actual_person_found.projects == expected_person.projects - end - end - end - - describe '#team' do - subject(:team) { roulette.team } - - context 'HTTP failure' do - before do - WebMock - .stub_request(:get, described_class::ROULETTE_DATA_URL) - .to_return(status: 404) - end - - it 'raises a pretty error' do - expect { team }.to raise_error(/Failed to read/) - end - end - - context 'JSON failure' do - before do - WebMock - .stub_request(:get, described_class::ROULETTE_DATA_URL) - .to_return(body: 'INVALID JSON') - end - - it 'raises a pretty error' do - expect { team }.to raise_error(/Failed to parse/) - end - end - - context 'success' do - before do - WebMock - .stub_request(:get, described_class::ROULETTE_DATA_URL) - .to_return(body: teammate_json) - end - - it 'returns an array of teammates' do - is_expected.to match_teammates([ - backend_maintainer, - frontend_reviewer, - frontend_maintainer, - software_engineer_in_test, - engineering_productivity_reviewer, - ci_template_reviewer - ]) - end - - it 'memoizes the result' do - expect(team.object_id).to eq(roulette.team.object_id) - end - end - end - - describe '#project_team' do - subject { roulette.project_team('gitlab-qa') } - - before do - WebMock - .stub_request(:get, described_class::ROULETTE_DATA_URL) - .to_return(body: teammate_json) - end - - it 'filters team by project_name' do - is_expected.to match_teammates([ - software_engineer_in_test - ]) - end - end - - describe '#spin_for_person' do - let(:person_tz_offset_hours) { 0.0 } - let(:person1) do - Tooling::Danger::Teammate.new( - 'username' => 'user1', - 'available' => true, - 'tz_offset_hours' => person_tz_offset_hours - ) - end - - let(:person2) do - Tooling::Danger::Teammate.new( - 'username' => 'user2', - 'available' => true, - 'tz_offset_hours' => person_tz_offset_hours) - end - - let(:author) do - Tooling::Danger::Teammate.new( - 'username' => 'johndoe', - 'available' => true, - 'tz_offset_hours' => 0.0) - end - - let(:unavailable) do - Tooling::Danger::Teammate.new( - 'username' => 'janedoe', - 'available' => false, - 'tz_offset_hours' => 0.0) - end - - before do - allow(subject).to receive(:mr_author_username).and_return(author.username) - end - - (-4..4).each do |utc_offset| - context "when local hour for person is #{10 + utc_offset} (offset: #{utc_offset})" do - let(:person_tz_offset_hours) { utc_offset } - - [false, true].each do |timezone_experiment| - context "with timezone_experiment == #{timezone_experiment}" do - it 'returns a random person' do - persons = [person1, person2] - - selected = subject.spin_for_person(persons, random: Random.new, timezone_experiment: timezone_experiment) - - expect(persons.map(&:username)).to include(selected.username) - end - end - end - end - end - - ((-12..-5).to_a + (5..12).to_a).each do |utc_offset| - context "when local hour for person is #{10 + utc_offset} (offset: #{utc_offset})" do - let(:person_tz_offset_hours) { utc_offset } - - [false, true].each do |timezone_experiment| - context "with timezone_experiment == #{timezone_experiment}" do - it 'returns a random person or nil' do - persons = [person1, person2] - - selected = subject.spin_for_person(persons, random: Random.new, timezone_experiment: timezone_experiment) - - if timezone_experiment - expect(selected).to be_nil - else - expect(persons.map(&:username)).to include(selected.username) - end - end - end - end - end - end - - it 'excludes unavailable persons' do - expect(subject.spin_for_person([unavailable], random: Random.new)).to be_nil - end - - it 'excludes mr.author' do - expect(subject.spin_for_person([author], random: Random.new)).to be_nil - end - end -end diff --git a/spec/tooling/danger/sidekiq_queues_spec.rb b/spec/tooling/danger/sidekiq_queues_spec.rb index c5fc8592621..9bffc7ee93d 100644 --- a/spec/tooling/danger/sidekiq_queues_spec.rb +++ b/spec/tooling/danger/sidekiq_queues_spec.rb @@ -1,20 +1,21 @@ # frozen_string_literal: true require 'rspec-parameterized' -require_relative 'danger_spec_helper' +require 'gitlab-dangerfiles' +require 'gitlab/dangerfiles/spec_helper' require_relative '../../../tooling/danger/sidekiq_queues' RSpec.describe Tooling::Danger::SidekiqQueues do - using RSpec::Parameterized::TableSyntax - include DangerSpecHelper + include_context "with dangerfile" - let(:fake_git) { double('fake-git') } - let(:fake_danger) { new_fake_danger.include(described_class) } + let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) } subject(:sidekiq_queues) { fake_danger.new(git: fake_git) } describe '#changed_queue_files' do + using RSpec::Parameterized::TableSyntax + where(:modified_files, :changed_queue_files) do %w(app/workers/all_queues.yml ee/app/workers/all_queues.yml foo) | %w(app/workers/all_queues.yml ee/app/workers/all_queues.yml) %w(app/workers/all_queues.yml ee/app/workers/all_queues.yml) | %w(app/workers/all_queues.yml ee/app/workers/all_queues.yml) diff --git a/spec/tooling/danger/teammate_spec.rb b/spec/tooling/danger/teammate_spec.rb deleted file mode 100644 index f3afdc6e912..00000000000 --- a/spec/tooling/danger/teammate_spec.rb +++ /dev/null @@ -1,225 +0,0 @@ -# frozen_string_literal: true - -require_relative '../../../tooling/danger/teammate' -require 'active_support/testing/time_helpers' -require 'rspec-parameterized' - -RSpec.describe Tooling::Danger::Teammate do - using RSpec::Parameterized::TableSyntax - - subject { described_class.new(options) } - - let(:tz_offset_hours) { 2.0 } - let(:options) do - { - 'username' => 'luigi', - 'projects' => projects, - 'role' => role, - 'markdown_name' => '[Luigi](https://gitlab.com/luigi) (`@luigi`)', - 'tz_offset_hours' => tz_offset_hours - } - end - - let(:capabilities) { ['reviewer backend'] } - let(:projects) { { project => capabilities } } - let(:role) { 'Engineer, Manage' } - let(:labels) { [] } - let(:project) { double } - - describe '#==' do - it 'compares Teammate username' do - joe1 = described_class.new('username' => 'joe', 'projects' => projects) - joe2 = described_class.new('username' => 'joe', 'projects' => []) - jane1 = described_class.new('username' => 'jane', 'projects' => projects) - jane2 = described_class.new('username' => 'jane', 'projects' => []) - - expect(joe1).to eq(joe2) - expect(jane1).to eq(jane2) - expect(jane1).not_to eq(nil) - expect(described_class.new('username' => nil)).not_to eq(nil) - end - end - - describe '#to_h' do - it 'returns the given options' do - expect(subject.to_h).to eq(options) - end - end - - context 'when having multiple capabilities' do - let(:capabilities) { ['reviewer backend', 'maintainer frontend', 'trainee_maintainer qa'] } - - it '#any_capability? returns true if the person has any capability for the category in the given project' do - expect(subject.any_capability?(project, :backend)).to be_truthy - expect(subject.any_capability?(project, :frontend)).to be_truthy - expect(subject.any_capability?(project, :qa)).to be_truthy - expect(subject.any_capability?(project, :engineering_productivity)).to be_falsey - end - - it '#reviewer? supports multiple roles per project' do - expect(subject.reviewer?(project, :backend, labels)).to be_truthy - end - - it '#traintainer? supports multiple roles per project' do - expect(subject.traintainer?(project, :qa, labels)).to be_truthy - end - - it '#maintainer? supports multiple roles per project' do - expect(subject.maintainer?(project, :frontend, labels)).to be_truthy - end - - context 'when labels contain devops::create and the category is test' do - let(:labels) { ['devops::create'] } - - context 'when role is Software Engineer in Test, Create' do - let(:role) { 'Software Engineer in Test, Create' } - - it '#reviewer? returns true' do - expect(subject.reviewer?(project, :test, labels)).to be_truthy - end - - it '#maintainer? returns false' do - expect(subject.maintainer?(project, :test, labels)).to be_falsey - end - - context 'when hyperlink is mangled in the role' do - let(:role) { '<a href="#">Software Engineer in Test</a>, Create' } - - it '#reviewer? returns true' do - expect(subject.reviewer?(project, :test, labels)).to be_truthy - end - end - end - - context 'when role is Software Engineer in Test' do - let(:role) { 'Software Engineer in Test' } - - it '#reviewer? returns false' do - expect(subject.reviewer?(project, :test, labels)).to be_falsey - end - end - - context 'when role is Software Engineer in Test, Manage' do - let(:role) { 'Software Engineer in Test, Manage' } - - it '#reviewer? returns false' do - expect(subject.reviewer?(project, :test, labels)).to be_falsey - end - end - - context 'when role is Backend Engineer, Engineering Productivity' do - let(:role) { 'Backend Engineer, Engineering Productivity' } - - it '#reviewer? returns true' do - expect(subject.reviewer?(project, :engineering_productivity, labels)).to be_truthy - end - - it '#maintainer? returns false' do - expect(subject.maintainer?(project, :engineering_productivity, labels)).to be_falsey - end - - context 'when capabilities include maintainer backend' do - let(:capabilities) { ['maintainer backend'] } - - it '#maintainer? returns true' do - expect(subject.maintainer?(project, :engineering_productivity, labels)).to be_truthy - end - end - - context 'when capabilities include maintainer engineering productivity' do - let(:capabilities) { ['maintainer engineering_productivity'] } - - it '#maintainer? returns true' do - expect(subject.maintainer?(project, :engineering_productivity, labels)).to be_truthy - end - end - - context 'when capabilities include trainee_maintainer backend' do - let(:capabilities) { ['trainee_maintainer backend'] } - - it '#traintainer? returns true' do - expect(subject.traintainer?(project, :engineering_productivity, labels)).to be_truthy - end - end - end - end - end - - context 'when having single capability' do - let(:capabilities) { 'reviewer backend' } - - it '#reviewer? supports one role per project' do - expect(subject.reviewer?(project, :backend, labels)).to be_truthy - end - - it '#traintainer? supports one role per project' do - expect(subject.traintainer?(project, :database, labels)).to be_falsey - end - - it '#maintainer? supports one role per project' do - expect(subject.maintainer?(project, :frontend, labels)).to be_falsey - end - end - - describe '#local_hour' do - include ActiveSupport::Testing::TimeHelpers - - around do |example| - travel_to(Time.utc(2020, 6, 23, 10)) { example.run } - end - - context 'when author is given' do - where(:tz_offset_hours, :expected_local_hour) do - -12 | 22 - -10 | 0 - 2 | 12 - 4 | 14 - 12 | 22 - end - - with_them do - it 'returns the correct local_hour' do - expect(subject.local_hour).to eq(expected_local_hour) - end - end - end - end - - describe '#markdown_name' do - it 'returns markdown name with timezone info' do - expect(subject.markdown_name).to eq("#{options['markdown_name']} (UTC+2)") - end - - context 'when offset is 1.5' do - let(:tz_offset_hours) { 1.5 } - - it 'returns markdown name with timezone info, not truncated' do - expect(subject.markdown_name).to eq("#{options['markdown_name']} (UTC+1.5)") - end - end - - context 'when author is given' do - where(:tz_offset_hours, :author_offset, :diff_text) do - -12 | -10 | "2 hours behind `@mario`" - -10 | -12 | "2 hours ahead of `@mario`" - -10 | 2 | "12 hours behind `@mario`" - 2 | 4 | "2 hours behind `@mario`" - 4 | 2 | "2 hours ahead of `@mario`" - 2 | 3 | "1 hour behind `@mario`" - 3 | 2 | "1 hour ahead of `@mario`" - 2 | 2 | "same timezone as `@mario`" - end - - with_them do - it 'returns markdown name with timezone info' do - author = described_class.new(options.merge('username' => 'mario', 'tz_offset_hours' => author_offset)) - - floored_offset_hours = subject.__send__(:floored_offset_hours) - utc_offset = floored_offset_hours >= 0 ? "+#{floored_offset_hours}" : floored_offset_hours - - expect(subject.markdown_name(author: author)).to eq("#{options['markdown_name']} (UTC#{utc_offset}, #{diff_text})") - end - end - end - end -end diff --git a/spec/tooling/danger/title_linting_spec.rb b/spec/tooling/danger/title_linting_spec.rb deleted file mode 100644 index 7bc1684cd87..00000000000 --- a/spec/tooling/danger/title_linting_spec.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -require 'rspec-parameterized' - -require_relative '../../../tooling/danger/title_linting' - -RSpec.describe Tooling::Danger::TitleLinting do - using RSpec::Parameterized::TableSyntax - - describe '#sanitize_mr_title' do - where(:mr_title, :expected_mr_title) do - '`My MR title`' | "\\`My MR title\\`" - 'WIP: My MR title' | 'My MR title' - 'Draft: My MR title' | 'My MR title' - '(Draft) My MR title' | 'My MR title' - '[Draft] My MR title' | 'My MR title' - '[DRAFT] My MR title' | 'My MR title' - 'DRAFT: My MR title' | 'My MR title' - 'DRAFT: `My MR title`' | "\\`My MR title\\`" - end - - with_them do - subject { described_class.sanitize_mr_title(mr_title) } - - it { is_expected.to eq(expected_mr_title) } - end - end - - describe '#remove_draft_flag' do - where(:mr_title, :expected_mr_title) do - 'WIP: My MR title' | 'My MR title' - 'Draft: My MR title' | 'My MR title' - '(Draft) My MR title' | 'My MR title' - '[Draft] My MR title' | 'My MR title' - '[DRAFT] My MR title' | 'My MR title' - 'DRAFT: My MR title' | 'My MR title' - end - - with_them do - subject { described_class.remove_draft_flag(mr_title) } - - it { is_expected.to eq(expected_mr_title) } - end - end - - describe '#has_draft_flag?' do - it 'returns true for a draft title' do - expect(described_class.has_draft_flag?('Draft: My MR title')).to be true - end - - it 'returns false for non draft title' do - expect(described_class.has_draft_flag?('My MR title')).to be false - end - end - - describe '#has_cherry_pick_flag?' do - [ - 'Cherry Pick !1234', - 'cherry-pick !1234', - 'CherryPick !1234' - ].each do |mr_title| - it 'returns true for cherry-pick title' do - expect(described_class.has_cherry_pick_flag?(mr_title)).to be true - end - end - - it 'returns false for non cherry-pick title' do - expect(described_class.has_cherry_pick_flag?('My MR title')).to be false - end - end - - describe '#has_run_all_rspec_flag?' do - it 'returns true for a title that includes RUN ALL RSPEC' do - expect(described_class.has_run_all_rspec_flag?('My MR title RUN ALL RSPEC')).to be true - end - - it 'returns true for a title that does not include RUN ALL RSPEC' do - expect(described_class.has_run_all_rspec_flag?('My MR title')).to be false - end - end - - describe '#has_run_as_if_foss_flag?' do - it 'returns true for a title that includes RUN AS-IF-FOSS' do - expect(described_class.has_run_as_if_foss_flag?('My MR title RUN AS-IF-FOSS')).to be true - end - - it 'returns true for a title that does not include RUN AS-IF-FOSS' do - expect(described_class.has_run_as_if_foss_flag?('My MR title')).to be false - end - end -end diff --git a/spec/tooling/danger/weightage/maintainers_spec.rb b/spec/tooling/danger/weightage/maintainers_spec.rb deleted file mode 100644 index b99ffe706a4..00000000000 --- a/spec/tooling/danger/weightage/maintainers_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require_relative '../../../../tooling/danger/weightage/maintainers' - -RSpec.describe Tooling::Danger::Weightage::Maintainers do - let(:multiplier) { Tooling::Danger::Weightage::CAPACITY_MULTIPLIER } - let(:regular_maintainer) { double('Teammate', reduced_capacity: false) } - let(:reduced_capacity_maintainer) { double('Teammate', reduced_capacity: true) } - let(:maintainers) do - [ - regular_maintainer, - reduced_capacity_maintainer - ] - end - - let(:maintainer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT * multiplier } - let(:reduced_capacity_maintainer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT } - - subject(:weighted_maintainers) { described_class.new(maintainers).execute } - - describe '#execute' do - it 'weights the maintainers overall' do - expect(weighted_maintainers.count).to eq maintainer_count + reduced_capacity_maintainer_count - end - - it 'has total count of regular maintainers' do - expect(weighted_maintainers.count { |r| r.object_id == regular_maintainer.object_id }).to eq maintainer_count - end - - it 'has count of reduced capacity maintainers' do - expect(weighted_maintainers.count { |r| r.object_id == reduced_capacity_maintainer.object_id }).to eq reduced_capacity_maintainer_count - end - end -end diff --git a/spec/tooling/danger/weightage/reviewers_spec.rb b/spec/tooling/danger/weightage/reviewers_spec.rb deleted file mode 100644 index 5693ce7a10c..00000000000 --- a/spec/tooling/danger/weightage/reviewers_spec.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -require_relative '../../../../tooling/danger/weightage/reviewers' - -RSpec.describe Tooling::Danger::Weightage::Reviewers do - let(:multiplier) { Tooling::Danger::Weightage::CAPACITY_MULTIPLIER } - let(:regular_reviewer) { double('Teammate', hungry: false, reduced_capacity: false) } - let(:hungry_reviewer) { double('Teammate', hungry: true, reduced_capacity: false) } - let(:reduced_capacity_reviewer) { double('Teammate', hungry: false, reduced_capacity: true) } - let(:reviewers) do - [ - hungry_reviewer, - regular_reviewer, - reduced_capacity_reviewer - ] - end - - let(:regular_traintainer) { double('Teammate', hungry: false, reduced_capacity: false) } - let(:hungry_traintainer) { double('Teammate', hungry: true, reduced_capacity: false) } - let(:reduced_capacity_traintainer) { double('Teammate', hungry: false, reduced_capacity: true) } - let(:traintainers) do - [ - hungry_traintainer, - regular_traintainer, - reduced_capacity_traintainer - ] - end - - let(:hungry_reviewer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT * multiplier + described_class::DEFAULT_REVIEWER_WEIGHT } - let(:hungry_traintainer_count) { described_class::TRAINTAINER_WEIGHT * multiplier + described_class::DEFAULT_REVIEWER_WEIGHT } - let(:reviewer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT * multiplier } - let(:traintainer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT * described_class::TRAINTAINER_WEIGHT * multiplier } - let(:reduced_capacity_reviewer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT } - let(:reduced_capacity_traintainer_count) { described_class::TRAINTAINER_WEIGHT } - - subject(:weighted_reviewers) { described_class.new(reviewers, traintainers).execute } - - describe '#execute', :aggregate_failures do - it 'weights the reviewers overall' do - reviewers_count = hungry_reviewer_count + reviewer_count + reduced_capacity_reviewer_count - traintainers_count = hungry_traintainer_count + traintainer_count + reduced_capacity_traintainer_count - - expect(weighted_reviewers.count).to eq reviewers_count + traintainers_count - end - - it 'has total count of hungry reviewers and traintainers' do - expect(weighted_reviewers.count(&:hungry)).to eq hungry_reviewer_count + hungry_traintainer_count - expect(weighted_reviewers.count { |r| r.object_id == hungry_reviewer.object_id }).to eq hungry_reviewer_count - expect(weighted_reviewers.count { |r| r.object_id == hungry_traintainer.object_id }).to eq hungry_traintainer_count - end - - it 'has total count of regular reviewers and traintainers' do - expect(weighted_reviewers.count { |r| r.object_id == regular_reviewer.object_id }).to eq reviewer_count - expect(weighted_reviewers.count { |r| r.object_id == regular_traintainer.object_id }).to eq traintainer_count - end - - it 'has count of reduced capacity reviewers' do - expect(weighted_reviewers.count(&:reduced_capacity)).to eq reduced_capacity_reviewer_count + reduced_capacity_traintainer_count - expect(weighted_reviewers.count { |r| r.object_id == reduced_capacity_reviewer.object_id }).to eq reduced_capacity_reviewer_count - expect(weighted_reviewers.count { |r| r.object_id == reduced_capacity_traintainer.object_id }).to eq reduced_capacity_traintainer_count - end - end -end diff --git a/spec/tooling/gitlab_danger_spec.rb b/spec/tooling/gitlab_danger_spec.rb deleted file mode 100644 index 20ac40d1d2a..00000000000 --- a/spec/tooling/gitlab_danger_spec.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -require_relative '../../tooling/gitlab_danger' - -RSpec.describe GitlabDanger do - let(:gitlab_danger_helper) { nil } - - subject { described_class.new(gitlab_danger_helper) } - - describe '.local_warning_message' do - it 'returns an informational message with rules that can run' do - expect(described_class.local_warning_message).to eq("==> Only the following Danger rules can be run locally: #{described_class::LOCAL_RULES.join(', ')}") - end - end - - describe '.success_message' do - it 'returns an informational success message' do - expect(described_class.success_message).to eq('==> No Danger rule violations!') - end - end - - describe '#rule_names' do - context 'when running locally' do - it 'returns local only rules' do - expect(subject.rule_names).to eq(described_class::LOCAL_RULES) - end - end - - context 'when running under CI' do - let(:gitlab_danger_helper) { double('danger_gitlab_helper') } - - it 'returns all rules' do - expect(subject.rule_names).to eq(described_class::LOCAL_RULES | described_class::CI_ONLY_RULES) - end - end - end - - describe '#html_link' do - context 'when running locally' do - it 'returns the same string' do - str = 'something' - - expect(subject.html_link(str)).to eq(str) - end - end - - context 'when running under CI' do - let(:gitlab_danger_helper) { double('danger_gitlab_helper') } - - it 'returns a HTML link formatted version of the string' do - str = 'something' - html_formatted_str = %Q{<a href="#{str}">#{str}</a>} - - expect(gitlab_danger_helper).to receive(:html_link).with(str).and_return(html_formatted_str) - - expect(subject.html_link(str)).to eq(html_formatted_str) - end - end - end - - describe '#ci?' do - context 'when gitlab_danger_helper is not available' do - it 'returns false' do - expect(subject.ci?).to be_falsey - end - end - - context 'when gitlab_danger_helper is available' do - let(:gitlab_danger_helper) { double('danger_gitlab_helper') } - - it 'returns true' do - expect(subject.ci?).to be_truthy - end - end - end -end diff --git a/spec/lib/rspec_flaky/config_spec.rb b/spec/tooling/rspec_flaky/config_spec.rb index 6b148599b67..12b5ed74cb2 100644 --- a/spec/lib/rspec_flaky/config_spec.rb +++ b/spec/tooling/rspec_flaky/config_spec.rb @@ -1,14 +1,23 @@ # frozen_string_literal: true -require 'spec_helper' +require 'rspec-parameterized' +require_relative '../../support/helpers/stub_env' + +require_relative '../../../tooling/rspec_flaky/config' RSpec.describe RspecFlaky::Config, :aggregate_failures do + include StubENV + before do # Stub these env variables otherwise specs don't behave the same on the CI stub_env('FLAKY_RSPEC_GENERATE_REPORT', nil) stub_env('SUITE_FLAKY_RSPEC_REPORT_PATH', nil) stub_env('FLAKY_RSPEC_REPORT_PATH', nil) stub_env('NEW_FLAKY_RSPEC_REPORT_PATH', nil) + # Ensure the behavior is the same locally and on CI (where Rails is defined since we run this test as part of the whole suite), i.e. Rails isn't defined + allow(described_class).to receive(:rails_path).and_wrap_original do |method, path| + path + end end describe '.generate_report?' do @@ -44,10 +53,7 @@ RSpec.describe RspecFlaky::Config, :aggregate_failures do describe '.suite_flaky_examples_report_path' do context "when ENV['SUITE_FLAKY_RSPEC_REPORT_PATH'] is not set" do it 'returns the default path' do - expect(Rails.root).to receive(:join).with('rspec_flaky/suite-report.json') - .and_return('root/rspec_flaky/suite-report.json') - - expect(described_class.suite_flaky_examples_report_path).to eq('root/rspec_flaky/suite-report.json') + expect(described_class.suite_flaky_examples_report_path).to eq('rspec_flaky/suite-report.json') end end @@ -65,10 +71,7 @@ RSpec.describe RspecFlaky::Config, :aggregate_failures do describe '.flaky_examples_report_path' do context "when ENV['FLAKY_RSPEC_REPORT_PATH'] is not set" do it 'returns the default path' do - expect(Rails.root).to receive(:join).with('rspec_flaky/report.json') - .and_return('root/rspec_flaky/report.json') - - expect(described_class.flaky_examples_report_path).to eq('root/rspec_flaky/report.json') + expect(described_class.flaky_examples_report_path).to eq('rspec_flaky/report.json') end end @@ -86,10 +89,7 @@ RSpec.describe RspecFlaky::Config, :aggregate_failures do describe '.new_flaky_examples_report_path' do context "when ENV['NEW_FLAKY_RSPEC_REPORT_PATH'] is not set" do it 'returns the default path' do - expect(Rails.root).to receive(:join).with('rspec_flaky/new-report.json') - .and_return('root/rspec_flaky/new-report.json') - - expect(described_class.new_flaky_examples_report_path).to eq('root/rspec_flaky/new-report.json') + expect(described_class.new_flaky_examples_report_path).to eq('rspec_flaky/new-report.json') end end diff --git a/spec/lib/rspec_flaky/example_spec.rb b/spec/tooling/rspec_flaky/example_spec.rb index 4b45a15c463..8ff280fd855 100644 --- a/spec/lib/rspec_flaky/example_spec.rb +++ b/spec/tooling/rspec_flaky/example_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'spec_helper' +require_relative '../../../tooling/rspec_flaky/example' RSpec.describe RspecFlaky::Example do let(:example_attrs) do diff --git a/spec/lib/rspec_flaky/flaky_example_spec.rb b/spec/tooling/rspec_flaky/flaky_example_spec.rb index b1647d5830a..ab652662c0b 100644 --- a/spec/lib/rspec_flaky/flaky_example_spec.rb +++ b/spec/tooling/rspec_flaky/flaky_example_spec.rb @@ -1,8 +1,14 @@ # frozen_string_literal: true -require 'spec_helper' +require 'active_support/testing/time_helpers' +require_relative '../../support/helpers/stub_env' + +require_relative '../../../tooling/rspec_flaky/flaky_example' RSpec.describe RspecFlaky::FlakyExample, :aggregate_failures do + include ActiveSupport::Testing::TimeHelpers + include StubENV + let(:flaky_example_attrs) do { example_id: 'spec/foo/bar_spec.rb:2', @@ -30,7 +36,7 @@ RSpec.describe RspecFlaky::FlakyExample, :aggregate_failures do } end - let(:example) { double(example_attrs) } + let(:example) { OpenStruct.new(example_attrs) } before do # Stub these env variables otherwise specs don't behave the same on the CI @@ -77,19 +83,33 @@ RSpec.describe RspecFlaky::FlakyExample, :aggregate_failures do shared_examples 'an up-to-date FlakyExample instance' do let(:flaky_example) { described_class.new(args) } - it 'updates the first_flaky_at' do - now = Time.now - expected_first_flaky_at = flaky_example.first_flaky_at || now - Timecop.freeze(now) { flaky_example.update_flakiness! } + it 'sets the first_flaky_at if none exists' do + args[:first_flaky_at] = nil - expect(flaky_example.first_flaky_at).to eq(expected_first_flaky_at) + freeze_time do + flaky_example.update_flakiness! + + expect(flaky_example.first_flaky_at).to eq(Time.now) + end + end + + it 'maintains the first_flaky_at if exists' do + flaky_example.update_flakiness! + expected_first_flaky_at = flaky_example.first_flaky_at + + travel_to(Time.now + 42) do + flaky_example.update_flakiness! + expect(flaky_example.first_flaky_at).to eq(expected_first_flaky_at) + end end it 'updates the last_flaky_at' do - now = Time.now - Timecop.freeze(now) { flaky_example.update_flakiness! } + travel_to(Time.now + 42) do + the_future = Time.now + flaky_example.update_flakiness! - expect(flaky_example.last_flaky_at).to eq(now) + expect(flaky_example.last_flaky_at).to eq(the_future) + end end it 'updates the flaky_reports' do diff --git a/spec/lib/rspec_flaky/flaky_examples_collection_spec.rb b/spec/tooling/rspec_flaky/flaky_examples_collection_spec.rb index b2fd1d3733a..823459e31b4 100644 --- a/spec/lib/rspec_flaky/flaky_examples_collection_spec.rb +++ b/spec/tooling/rspec_flaky/flaky_examples_collection_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'spec_helper' +require_relative '../../../tooling/rspec_flaky/flaky_examples_collection' RSpec.describe RspecFlaky::FlakyExamplesCollection, :aggregate_failures do let(:collection_hash) do diff --git a/spec/lib/rspec_flaky/listener_spec.rb b/spec/tooling/rspec_flaky/listener_spec.rb index 10ed724d4de..429724a20cf 100644 --- a/spec/lib/rspec_flaky/listener_spec.rb +++ b/spec/tooling/rspec_flaky/listener_spec.rb @@ -1,8 +1,14 @@ # frozen_string_literal: true -require 'spec_helper' +require 'active_support/testing/time_helpers' +require_relative '../../support/helpers/stub_env' + +require_relative '../../../tooling/rspec_flaky/listener' RSpec.describe RspecFlaky::Listener, :aggregate_failures do + include ActiveSupport::Testing::TimeHelpers + include StubENV + let(:already_flaky_example_uid) { '6e869794f4cfd2badd93eb68719371d1' } let(:suite_flaky_example_report) do { @@ -130,14 +136,13 @@ RSpec.describe RspecFlaky::Listener, :aggregate_failures do it 'changes the flaky examples hash' do new_example = RspecFlaky::Example.new(rspec_example) - now = Time.now - Timecop.freeze(now) do + travel_to(Time.now + 42) do + the_future = Time.now expect { listener.example_passed(notification) } .to change { listener.flaky_examples[new_example.uid].to_h } + expect(listener.flaky_examples[new_example.uid].to_h) + .to eq(expected_flaky_example.merge(last_flaky_at: the_future)) end - - expect(listener.flaky_examples[new_example.uid].to_h) - .to eq(expected_flaky_example.merge(last_flaky_at: now)) end end @@ -157,14 +162,13 @@ RSpec.describe RspecFlaky::Listener, :aggregate_failures do it 'changes the all flaky examples hash' do new_example = RspecFlaky::Example.new(rspec_example) - now = Time.now - Timecop.freeze(now) do + travel_to(Time.now + 42) do + the_future = Time.now expect { listener.example_passed(notification) } .to change { listener.flaky_examples[new_example.uid].to_h } + expect(listener.flaky_examples[new_example.uid].to_h) + .to eq(expected_flaky_example.merge(first_flaky_at: the_future, last_flaky_at: the_future)) end - - expect(listener.flaky_examples[new_example.uid].to_h) - .to eq(expected_flaky_example.merge(first_flaky_at: now, last_flaky_at: now)) end end @@ -198,6 +202,10 @@ RSpec.describe RspecFlaky::Listener, :aggregate_failures do let(:notification_new_flaky_rspec_example) { double(example: new_flaky_rspec_example) } let(:notification_already_flaky_rspec_example) { double(example: already_flaky_rspec_example) } + before do + allow(Kernel).to receive(:warn) + end + context 'when a report file path is set by FLAKY_RSPEC_REPORT_PATH' do it 'delegates the writes to RspecFlaky::Report' do listener.example_passed(notification_new_flaky_rspec_example) diff --git a/spec/lib/rspec_flaky/report_spec.rb b/spec/tooling/rspec_flaky/report_spec.rb index 5cacfdb82fb..6c364cd5cd3 100644 --- a/spec/lib/rspec_flaky/report_spec.rb +++ b/spec/tooling/rspec_flaky/report_spec.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true -require 'spec_helper' +require 'tempfile' + +require_relative '../../../tooling/rspec_flaky/report' RSpec.describe RspecFlaky::Report, :aggregate_failures do let(:thirty_one_days) { 3600 * 24 * 31 } @@ -30,10 +32,14 @@ RSpec.describe RspecFlaky::Report, :aggregate_failures do let(:flaky_examples) { RspecFlaky::FlakyExamplesCollection.new(collection_hash) } let(:report) { described_class.new(flaky_examples) } + before do + allow(Kernel).to receive(:warn) + end + describe '.load' do let!(:report_file) do Tempfile.new(%w[rspec_flaky_report .json]).tap do |f| - f.write(Gitlab::Json.pretty_generate(suite_flaky_example_report)) + f.write(JSON.pretty_generate(suite_flaky_example_report)) # rubocop:disable Gitlab/Json f.rewind end end @@ -50,7 +56,7 @@ RSpec.describe RspecFlaky::Report, :aggregate_failures do describe '.load_json' do let(:report_json) do - Gitlab::Json.pretty_generate(suite_flaky_example_report) + JSON.pretty_generate(suite_flaky_example_report) # rubocop:disable Gitlab/Json end it 'loads the report file' do @@ -73,7 +79,7 @@ RSpec.describe RspecFlaky::Report, :aggregate_failures do end describe '#write' do - let(:report_file_path) { Rails.root.join('tmp', 'rspec_flaky_report.json') } + let(:report_file_path) { File.join('tmp', 'rspec_flaky_report.json') } before do FileUtils.rm(report_file_path) if File.exist?(report_file_path) @@ -105,7 +111,7 @@ RSpec.describe RspecFlaky::Report, :aggregate_failures do expect(File.exist?(report_file_path)).to be(true) expect(File.read(report_file_path)) - .to eq(Gitlab::Json.pretty_generate(report.flaky_examples.to_h)) + .to eq(JSON.pretty_generate(report.flaky_examples.to_h)) # rubocop:disable Gitlab/Json end end end diff --git a/spec/uploaders/dependency_proxy/file_uploader_spec.rb b/spec/uploaders/dependency_proxy/file_uploader_spec.rb index 724a9c42f47..6e94a661d6d 100644 --- a/spec/uploaders/dependency_proxy/file_uploader_spec.rb +++ b/spec/uploaders/dependency_proxy/file_uploader_spec.rb @@ -2,25 +2,43 @@ require 'spec_helper' RSpec.describe DependencyProxy::FileUploader do - let(:blob) { create(:dependency_proxy_blob) } - let(:uploader) { described_class.new(blob, :file) } - let(:path) { Gitlab.config.dependency_proxy.storage_path } + describe 'DependencyProxy::Blob uploader' do + let_it_be(:blob) { create(:dependency_proxy_blob) } + let_it_be(:path) { Gitlab.config.dependency_proxy.storage_path } + let(:uploader) { described_class.new(blob, :file) } - subject { uploader } + subject { uploader } - it_behaves_like "builds correct paths", - store_dir: %r[\h{2}/\h{2}], - cache_dir: %r[/dependency_proxy/tmp/cache], - work_dir: %r[/dependency_proxy/tmp/work] + it_behaves_like "builds correct paths", + store_dir: %r[\h{2}/\h{2}], + cache_dir: %r[/dependency_proxy/tmp/cache], + work_dir: %r[/dependency_proxy/tmp/work] + + context 'object store is remote' do + before do + stub_dependency_proxy_object_storage + end - context 'object store is remote' do - before do - stub_dependency_proxy_object_storage + include_context 'with storage', described_class::Store::REMOTE + + it_behaves_like "builds correct paths", + store_dir: %r[\h{2}/\h{2}] end + end - include_context 'with storage', described_class::Store::REMOTE + describe 'DependencyProxy::Manifest uploader' do + let_it_be(:manifest) { create(:dependency_proxy_manifest) } + let_it_be(:initial_content_type) { 'application/json' } + let_it_be(:fixture_file) { fixture_file_upload('spec/fixtures/dependency_proxy/manifest', initial_content_type) } + let(:uploader) { described_class.new(manifest, :file) } - it_behaves_like "builds correct paths", - store_dir: %r[\h{2}/\h{2}] + subject { uploader } + + it 'will change upload file content type to match the model content type', :aggregate_failures do + uploader.cache!(fixture_file) + + expect(uploader.file.content_type).to eq(manifest.content_type) + expect(uploader.file.content_type).not_to eq(initial_content_type) + end end end diff --git a/spec/validators/zoom_url_validator_spec.rb b/spec/validators/gitlab/utils/zoom_url_validator_spec.rb index 7d5c94bc249..bc8236a2f5c 100644 --- a/spec/validators/zoom_url_validator_spec.rb +++ b/spec/validators/gitlab/utils/zoom_url_validator_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe ZoomUrlValidator do +RSpec.describe Gitlab::Utils::ZoomUrlValidator do let(:zoom_meeting) { build(:zoom_meeting) } describe 'validations' do diff --git a/spec/views/admin/application_settings/_package_registry.html.haml_spec.rb b/spec/views/admin/application_settings/_package_registry.html.haml_spec.rb index ef40829c29b..e0aa2fc8d56 100644 --- a/spec/views/admin/application_settings/_package_registry.html.haml_spec.rb +++ b/spec/views/admin/application_settings/_package_registry.html.haml_spec.rb @@ -30,8 +30,8 @@ RSpec.describe 'admin/application_settings/_package_registry' do expect(rendered).to have_field('Maximum Maven package file size in bytes', type: 'number') expect(page.find_field('Maximum Maven package file size in bytes').value).to eq(default_plan_limits.maven_max_file_size.to_s) - expect(rendered).to have_field('Maximum NPM package file size in bytes', type: 'number') - expect(page.find_field('Maximum NPM package file size in bytes').value).to eq(default_plan_limits.npm_max_file_size.to_s) + expect(rendered).to have_field('Maximum npm package file size in bytes', type: 'number') + expect(page.find_field('Maximum npm package file size in bytes').value).to eq(default_plan_limits.npm_max_file_size.to_s) expect(rendered).to have_field('Maximum NuGet package file size in bytes', type: 'number') expect(page.find_field('Maximum NuGet package file size in bytes').value).to eq(default_plan_limits.nuget_max_file_size.to_s) @@ -48,18 +48,18 @@ RSpec.describe 'admin/application_settings/_package_registry' do end context 'with multiple plans' do - let_it_be(:plan) { create(:plan, name: 'Gold') } - let_it_be(:gold_plan_limits) { create(:plan_limits, :with_package_file_sizes, plan: plan) } + let_it_be(:plan) { create(:plan, name: 'Ultimate') } + let_it_be(:ultimate_plan_limits) { create(:plan_limits, :with_package_file_sizes, plan: plan) } before do - assign(:plans, [default_plan_limits.plan, gold_plan_limits.plan]) + assign(:plans, [default_plan_limits.plan, ultimate_plan_limits.plan]) end it 'displays the plan name when there is more than one plan' do subject expect(page).to have_content('Default') - expect(page).to have_content('Gold') + expect(page).to have_content('Ultimate') end end end diff --git a/spec/views/admin/application_settings/_repository_storage.html.haml_spec.rb b/spec/views/admin/application_settings/_repository_storage.html.haml_spec.rb index 2915fe1964f..dc8f259eb56 100644 --- a/spec/views/admin/application_settings/_repository_storage.html.haml_spec.rb +++ b/spec/views/admin/application_settings/_repository_storage.html.haml_spec.rb @@ -3,34 +3,49 @@ require 'spec_helper' RSpec.describe 'admin/application_settings/_repository_storage.html.haml' do - let(:app_settings) { create(:application_setting) } - let(:repository_storages_weighted_attributes) { [:repository_storages_weighted_default, :repository_storages_weighted_mepmep, :repository_storages_weighted_foobar]} - let(:repository_storages_weighted) do - { - "default" => 100, - "mepmep" => 50 - } - end + let(:app_settings) { build(:application_setting, repository_storages_weighted: repository_storages_weighted) } before do - allow(app_settings).to receive(:repository_storages_weighted).and_return(repository_storages_weighted) - allow(app_settings).to receive(:repository_storages_weighted_mepmep).and_return(100) - allow(app_settings).to receive(:repository_storages_weighted_foobar).and_return(50) + stub_storage_settings({ 'default': {}, 'mepmep': {}, 'foobar': {} }) assign(:application_setting, app_settings) - allow(ApplicationSetting).to receive(:repository_storages_weighted_attributes).and_return(repository_storages_weighted_attributes) end - context 'when multiple storages are available' do + context 'additional storage config' do + let(:repository_storages_weighted) do + { + 'default' => 100, + 'mepmep' => 50 + } + end + it 'lists them all' do render - # lists storages that are saved with weights - repository_storages_weighted.each do |storage_name, storage_weight| + Gitlab.config.repositories.storages.keys.each do |storage_name| expect(rendered).to have_content(storage_name) end - # lists storage not saved with weight expect(rendered).to have_content('foobar') end end + + context 'fewer storage configs' do + let(:repository_storages_weighted) do + { + 'default' => 100, + 'mepmep' => 50, + 'something_old' => 100 + } + end + + it 'lists only configured storages' do + render + + Gitlab.config.repositories.storages.keys.each do |storage_name| + expect(rendered).to have_content(storage_name) + end + + expect(rendered).not_to have_content('something_old') + end + end end diff --git a/spec/views/groups/show.html.haml_spec.rb b/spec/views/groups/show.html.haml_spec.rb deleted file mode 100644 index a53aab43c18..00000000000 --- a/spec/views/groups/show.html.haml_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'groups/show.html.haml' do - let_it_be(:user) { build(:user) } - let_it_be(:group) { create(:group) } - - before do - assign(:group, group) - end - - context 'when rendering with the layout' do - subject(:render_page) { render template: 'groups/show.html.haml', layout: 'layouts/group' } - - describe 'invite team members' do - before do - allow(view).to receive(:session).and_return({}) - allow(view).to receive(:current_user_mode).and_return(Gitlab::Auth::CurrentUserMode.new(user)) - allow(view).to receive(:current_user).and_return(user) - allow(view).to receive(:experiment_enabled?).and_return(false) - allow(view).to receive(:group_path).and_return('') - allow(view).to receive(:group_shared_path).and_return('') - allow(view).to receive(:group_archived_path).and_return('') - end - - context 'when invite team members is not available in sidebar' do - before do - allow(view).to receive(:can_invite_members_for_group?).and_return(false) - end - - it 'does not display the js-invite-members-trigger' do - render_page - - expect(rendered).not_to have_selector('.js-invite-members-trigger') - end - end - - context 'when invite team members is available' do - before do - allow(view).to receive(:can_invite_members_for_group?).and_return(true) - end - - it 'includes the div for js-invite-members-trigger' do - render_page - - expect(rendered).to have_selector('.js-invite-members-trigger') - end - end - end - end -end diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb index e34d8b91b38..99d7dfc8acb 100644 --- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb +++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb @@ -204,7 +204,7 @@ RSpec.describe 'layouts/nav/sidebar/_project' do it 'does not show the ci/cd settings tab' do render - expect(rendered).not_to have_link('CI / CD', href: project_settings_ci_cd_path(project)) + expect(rendered).not_to have_link('CI/CD', href: project_settings_ci_cd_path(project)) end end @@ -214,7 +214,7 @@ RSpec.describe 'layouts/nav/sidebar/_project' do it 'shows the ci/cd settings tab' do render - expect(rendered).to have_link('CI / CD', href: project_settings_ci_cd_path(project)) + expect(rendered).to have_link('CI/CD', href: project_settings_ci_cd_path(project)) end end end diff --git a/spec/views/notify/change_in_merge_request_draft_status_email.html.haml_spec.rb b/spec/views/notify/change_in_merge_request_draft_status_email.html.haml_spec.rb new file mode 100644 index 00000000000..6c25eba03b9 --- /dev/null +++ b/spec/views/notify/change_in_merge_request_draft_status_email.html.haml_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'notify/change_in_merge_request_draft_status_email.html.haml' do + let(:user) { create(:user) } + let(:merge_request) { create(:merge_request) } + + before do + assign(:updated_by_user, user) + assign(:merge_request, merge_request) + end + + it 'renders the email correctly' do + render + + expect(rendered).to have_content("#{user.name} changed the draft status of merge request #{merge_request.to_reference}") + end +end diff --git a/spec/views/notify/change_in_merge_request_draft_status_email.text.erb_spec.rb b/spec/views/notify/change_in_merge_request_draft_status_email.text.erb_spec.rb new file mode 100644 index 00000000000..a05c20fd8c4 --- /dev/null +++ b/spec/views/notify/change_in_merge_request_draft_status_email.text.erb_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe 'notify/change_in_merge_request_draft_status_email.text.erb' do + let(:user) { create(:user) } + let(:merge_request) { create(:merge_request) } + + before do + assign(:updated_by_user, user) + assign(:merge_request, merge_request) + end + + it_behaves_like 'renders plain text email correctly' + + it 'renders the email correctly' do + render + + expect(rendered).to have_content("#{user.name} changed the draft status of merge request #{merge_request.to_reference}") + end +end diff --git a/spec/views/projects/_home_panel.html.haml_spec.rb b/spec/views/projects/_home_panel.html.haml_spec.rb index cc0eb9919da..d329c57af00 100644 --- a/spec/views/projects/_home_panel.html.haml_spec.rb +++ b/spec/views/projects/_home_panel.html.haml_spec.rb @@ -9,7 +9,6 @@ RSpec.describe 'projects/_home_panel' do let(:project) { create(:project) } before do - stub_feature_flags(vue_notification_dropdown: false) assign(:project, project) allow(view).to receive(:current_user).and_return(user) @@ -25,11 +24,10 @@ RSpec.describe 'projects/_home_panel' do assign(:notification_setting, notification_settings) end - it 'makes it possible to set notification level' do + it 'renders Vue app root' do render - expect(view).to render_template('shared/notifications/_new_button') - expect(rendered).to have_selector('.notification-dropdown') + expect(rendered).to have_selector('.js-vue-notification-dropdown') end end @@ -40,10 +38,10 @@ RSpec.describe 'projects/_home_panel' do assign(:notification_setting, nil) end - it 'is not possible to set notification level' do + it 'does not render Vue app root' do render - expect(rendered).not_to have_selector('.notification_dropdown') + expect(rendered).not_to have_selector('.js-vue-notification-dropdown') end end end diff --git a/spec/views/projects/commit/_commit_box.html.haml_spec.rb b/spec/views/projects/commit/_commit_box.html.haml_spec.rb index 9c97696493e..9d18519ade6 100644 --- a/spec/views/projects/commit/_commit_box.html.haml_spec.rb +++ b/spec/views/projects/commit/_commit_box.html.haml_spec.rb @@ -21,12 +21,37 @@ RSpec.describe 'projects/commit/_commit_box.html.haml' do end context 'when there is a pipeline present' do + context 'when pipeline has stages' do + before do + pipeline = create(:ci_pipeline, project: project, sha: project.commit.id, status: 'success') + create(:ci_build, pipeline: pipeline, stage: 'build') + + assign(:last_pipeline, project.commit.last_pipeline) + end + + it 'shows pipeline stages in vue' do + render + + expect(rendered).to have_selector('.js-commit-pipeline-mini-graph') + end + + it 'shows pipeline stages in haml when feature flag is disabled' do + stub_feature_flags(ci_commit_pipeline_mini_graph_vue: false) + + render + + expect(rendered).to have_selector('.js-commit-pipeline-graph') + end + end + context 'when there are multiple pipelines for a commit' do it 'shows the last pipeline' do create(:ci_pipeline, project: project, sha: project.commit.id, status: 'success') create(:ci_pipeline, project: project, sha: project.commit.id, status: 'canceled') third_pipeline = create(:ci_pipeline, project: project, sha: project.commit.id, status: 'failed') + assign(:last_pipeline, third_pipeline) + render expect(rendered).to have_text("Pipeline ##{third_pipeline.id} failed") @@ -40,6 +65,8 @@ RSpec.describe 'projects/commit/_commit_box.html.haml' do end it 'shows correct pipeline description' do + assign(:last_pipeline, pipeline) + render expect(rendered).to have_text "Pipeline ##{pipeline.id} " \ diff --git a/spec/views/projects/empty.html.haml_spec.rb b/spec/views/projects/empty.html.haml_spec.rb index 6762dcd22d5..de83722160e 100644 --- a/spec/views/projects/empty.html.haml_spec.rb +++ b/spec/views/projects/empty.html.haml_spec.rb @@ -79,41 +79,4 @@ RSpec.describe 'projects/empty' do it_behaves_like 'no invite member info' end end - - context 'when rendering with the layout' do - subject(:render_page) { render template: 'projects/empty.html.haml', layout: 'layouts/project' } - - describe 'invite team members' do - before do - allow(view).to receive(:session).and_return({}) - allow(view).to receive(:current_user_mode).and_return(Gitlab::Auth::CurrentUserMode.new(user)) - allow(view).to receive(:current_user).and_return(user) - allow(view).to receive(:experiment_enabled?).and_return(false) - end - - context 'when invite team members is not available in sidebar' do - before do - allow(view).to receive(:can_invite_members_for_project?).and_return(false) - end - - it 'does not display the js-invite-members-trigger' do - render_page - - expect(rendered).not_to have_selector('.js-invite-members-trigger') - end - end - - context 'when invite team members is available' do - before do - allow(view).to receive(:can_invite_members_for_project?).and_return(true) - end - - it 'includes the div for js-invite-members-trigger' do - render_page - - expect(rendered).to have_selector('.js-invite-members-trigger') - end - end - end - end end diff --git a/spec/views/projects/issues/import_csv/_button.html.haml_spec.rb b/spec/views/projects/issues/import_csv/_button.html.haml_spec.rb deleted file mode 100644 index 8bc0a00d71c..00000000000 --- a/spec/views/projects/issues/import_csv/_button.html.haml_spec.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'projects/issues/import_csv/_button' do - include Devise::Test::ControllerHelpers - - context 'when the user does not have edit permissions' do - before do - render - end - - it 'shows a dropdown button to import CSV' do - expect(rendered).to have_text('Import CSV') - end - - it 'does not show a button to import from Jira' do - expect(rendered).not_to have_text('Import from Jira') - end - end - - context 'when the user has edit permissions' do - let(:project) { create(:project) } - let(:current_user) { create(:user, maintainer_projects: [project]) } - - before do - allow(view).to receive(:project_import_jira_path).and_return('import/jira') - allow(view).to receive(:current_user).and_return(current_user) - - assign(:project, project) - - render - end - - it 'shows a dropdown button to import CSV' do - expect(rendered).to have_text('Import CSV') - end - - it 'shows a button to import from Jira' do - expect(rendered).to have_text('Import from Jira') - end - end -end diff --git a/spec/views/projects/merge_requests/show.html.haml_spec.rb b/spec/views/projects/merge_requests/show.html.haml_spec.rb index db41c9b5374..40d11342ec4 100644 --- a/spec/views/projects/merge_requests/show.html.haml_spec.rb +++ b/spec/views/projects/merge_requests/show.html.haml_spec.rb @@ -2,16 +2,14 @@ require 'spec_helper' -RSpec.describe 'projects/merge_requests/show.html.haml' do - include Spec::Support::Helpers::Features::MergeRequestHelpers +RSpec.describe 'projects/merge_requests/show.html.haml', :aggregate_failures do + include_context 'merge request show action' before do - allow(view).to receive(:experiment_enabled?).and_return(false) + merge_request.reload end context 'when the merge request is open' do - include_context 'open merge request show action' - it 'shows the "Mark as draft" button' do render @@ -22,20 +20,8 @@ RSpec.describe 'projects/merge_requests/show.html.haml' do end context 'when the merge request is closed' do - include_context 'closed merge request show action' - - describe 'merge request assignee sidebar' do - context 'when assignee is allowed to merge' do - it 'does not show a warning icon' do - closed_merge_request.update!(assignee_id: user.id) - project.add_maintainer(user) - assign(:issuable_sidebar, serialize_issuable_sidebar(user, project, closed_merge_request)) - - render - - expect(rendered).not_to have_css('.merge-icon') - end - end + before do + merge_request.close! end it 'shows the "Reopen" button' do @@ -46,15 +32,15 @@ RSpec.describe 'projects/merge_requests/show.html.haml' do expect(rendered).to have_css('a', visible: false, text: 'Close') end - it 'does not show the "Reopen" button when the source project does not exist' do - unlink_project.execute - closed_merge_request.reload - preload_view_requirements(closed_merge_request, note) + context 'when source project does not exist' do + it 'does not show the "Reopen" button' do + allow(merge_request).to receive(:source_project).and_return(nil) - render + render - expect(rendered).to have_css('a', visible: false, text: 'Reopen') - expect(rendered).to have_css('a', visible: false, text: 'Close') + expect(rendered).to have_css('a', visible: false, text: 'Reopen') + expect(rendered).to have_css('a', visible: false, text: 'Close') + end end end end diff --git a/spec/views/projects/settings/operations/show.html.haml_spec.rb b/spec/views/projects/settings/operations/show.html.haml_spec.rb index a22853d40d8..b2dd3556098 100644 --- a/spec/views/projects/settings/operations/show.html.haml_spec.rb +++ b/spec/views/projects/settings/operations/show.html.haml_spec.rb @@ -59,7 +59,7 @@ RSpec.describe 'projects/settings/operations/show' do expect(rendered).to have_content _('Prometheus') expect(rendered).to have_content _('Link Prometheus monitoring to GitLab.') - expect(rendered).to have_content _('To enable the installation of Prometheus on your clusters, deactivate the manual configuration below') + expect(rendered).to have_content _('To enable the installation of Prometheus on your clusters, deactivate the manual configuration.') end end @@ -71,7 +71,7 @@ RSpec.describe 'projects/settings/operations/show' do it 'renders the Operations Settings page' do render - expect(rendered).not_to have_content _('Select the Active checkbox to override the Auto Configuration with custom settings. If unchecked, Auto Configuration settings are used.') + expect(rendered).not_to have_content _('Auto configuration settings are used unless you override their values here.') end end end diff --git a/spec/views/projects/show.html.haml_spec.rb b/spec/views/projects/show.html.haml_spec.rb deleted file mode 100644 index 995e31e83af..00000000000 --- a/spec/views/projects/show.html.haml_spec.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'projects/show.html.haml' do - let_it_be(:user) { build(:user) } - let_it_be(:project) { ProjectPresenter.new(create(:project, :repository), current_user: user) } - - before do - assign(:project, project) - end - - context 'when rendering with the layout' do - subject(:render_page) { render template: 'projects/show.html.haml', layout: 'layouts/project' } - - describe 'invite team members' do - before do - allow(view).to receive(:event_filter_link) - allow(view).to receive(:session).and_return({}) - allow(view).to receive(:current_user_mode).and_return(Gitlab::Auth::CurrentUserMode.new(user)) - allow(view).to receive(:current_user).and_return(user) - allow(view).to receive(:experiment_enabled?).and_return(false) - allow(view).to receive(:add_page_startup_graphql_call) - end - - context 'when invite team members is not available in sidebar' do - before do - allow(view).to receive(:can_invite_members_for_project?).and_return(false) - end - - it 'does not display the js-invite-members-trigger' do - render_page - - expect(rendered).not_to have_selector('.js-invite-members-trigger') - end - end - - context 'when invite team members is available' do - before do - allow(view).to receive(:can_invite_members_for_project?).and_return(true) - end - - it 'includes the div for js-invite-members-trigger' do - render_page - - expect(rendered).to have_selector('.js-invite-members-trigger') - end - end - end - end -end diff --git a/spec/views/shared/snippets/_snippet.html.haml_spec.rb b/spec/views/shared/snippets/_snippet.html.haml_spec.rb new file mode 100644 index 00000000000..712021ec1e1 --- /dev/null +++ b/spec/views/shared/snippets/_snippet.html.haml_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'shared/snippets/_snippet.html.haml' do + let_it_be(:snippet) { create(:snippet) } + + before do + allow(view).to receive(:current_application_settings).and_return(Gitlab::CurrentSettings.current_application_settings) + allow(view).to receive(:can?) { true } + + @noteable_meta_data = Class.new { include Gitlab::NoteableMetadata }.new.noteable_meta_data([snippet], 'Snippet') + end + + context 'snippet with statistics' do + it 'renders correct file count and tooltip' do + snippet.statistics.file_count = 3 + + render 'shared/snippets/snippet', snippet: snippet + + expect(rendered).to have_selector("span.file_count", text: '3') + expect(rendered).to have_selector("span.file_count[title=\"3 files\"]") + end + + it 'renders correct file count and tooltip when file_count is 1' do + snippet.statistics.file_count = 1 + + render 'shared/snippets/snippet', snippet: snippet + + expect(rendered).to have_selector("span.file_count", text: '1') + expect(rendered).to have_selector("span.file_count[title=\"1 file\"]") + end + + it 'does not render file count when file count is 0' do + snippet.statistics.file_count = 0 + + render 'shared/snippets/snippet', snippet: snippet + + expect(rendered).not_to have_selector('span.file_count') + end + end + + context 'snippet without statistics' do + it 'does not render file count if statistics are not present' do + snippet.statistics = nil + + render 'shared/snippets/snippet', snippet: snippet + + expect(rendered).not_to have_selector('span.file_count') + end + end +end diff --git a/spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb b/spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb index c7de8553d86..da0cbe37400 100644 --- a/spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb +++ b/spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb @@ -6,12 +6,12 @@ RSpec.describe Analytics::InstanceStatistics::CountJobTriggerWorker do it_behaves_like 'an idempotent worker' context 'triggers a job for each measurement identifiers' do - let(:expected_count) { Analytics::InstanceStatistics::Measurement.identifier_query_mapping.keys.size } + let(:expected_count) { Analytics::UsageTrends::Measurement.identifier_query_mapping.keys.size } it 'triggers CounterJobWorker jobs' do subject.perform - expect(Analytics::InstanceStatistics::CounterJobWorker.jobs.count).to eq(expected_count) + expect(Analytics::UsageTrends::CounterJobWorker.jobs.count).to eq(expected_count) end end end diff --git a/spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb b/spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb index 667ec0bcb75..4994fec44ab 100644 --- a/spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb +++ b/spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Analytics::InstanceStatistics::CounterJobWorker do let_it_be(:user_1) { create(:user) } let_it_be(:user_2) { create(:user) } - let(:users_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:users) } + let(:users_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:users) } let(:recorded_at) { Time.zone.now } let(:job_args) { [users_measurement_identifier, user_1.id, user_2.id, recorded_at] } @@ -18,7 +18,7 @@ RSpec.describe Analytics::InstanceStatistics::CounterJobWorker do it 'counts a scope and stores the result' do subject - measurement = Analytics::InstanceStatistics::Measurement.users.first + measurement = Analytics::UsageTrends::Measurement.users.first expect(measurement.recorded_at).to be_like_time(recorded_at) expect(measurement.identifier).to eq('users') expect(measurement.count).to eq(2) @@ -26,14 +26,14 @@ RSpec.describe Analytics::InstanceStatistics::CounterJobWorker do end context 'when no records are in the database' do - let(:users_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:groups) } + let(:users_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:groups) } subject { described_class.new.perform(users_measurement_identifier, nil, nil, recorded_at) } it 'sets 0 as the count' do subject - measurement = Analytics::InstanceStatistics::Measurement.groups.first + measurement = Analytics::UsageTrends::Measurement.groups.first expect(measurement.recorded_at).to be_like_time(recorded_at) expect(measurement.identifier).to eq('groups') expect(measurement.count).to eq(0) @@ -49,19 +49,19 @@ RSpec.describe Analytics::InstanceStatistics::CounterJobWorker do it 'does not insert anything when BatchCount returns error' do allow(Gitlab::Database::BatchCount).to receive(:batch_count).and_return(Gitlab::Database::BatchCounter::FALLBACK) - expect { subject }.not_to change { Analytics::InstanceStatistics::Measurement.count } + expect { subject }.not_to change { Analytics::UsageTrends::Measurement.count } end context 'when pipelines_succeeded identifier is passed' do let_it_be(:pipeline) { create(:ci_pipeline, :success) } - let(:successful_pipelines_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:pipelines_succeeded) } + let(:successful_pipelines_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:pipelines_succeeded) } let(:job_args) { [successful_pipelines_measurement_identifier, pipeline.id, pipeline.id, recorded_at] } it 'counts successful pipelines' do subject - measurement = Analytics::InstanceStatistics::Measurement.pipelines_succeeded.first + measurement = Analytics::UsageTrends::Measurement.pipelines_succeeded.first expect(measurement.recorded_at).to be_like_time(recorded_at) expect(measurement.identifier).to eq('pipelines_succeeded') expect(measurement.count).to eq(1) diff --git a/spec/workers/analytics/usage_trends/count_job_trigger_worker_spec.rb b/spec/workers/analytics/usage_trends/count_job_trigger_worker_spec.rb new file mode 100644 index 00000000000..735e4a214a9 --- /dev/null +++ b/spec/workers/analytics/usage_trends/count_job_trigger_worker_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Analytics::UsageTrends::CountJobTriggerWorker do + it_behaves_like 'an idempotent worker' + + context 'triggers a job for each measurement identifiers' do + let(:expected_count) { Analytics::UsageTrends::Measurement.identifier_query_mapping.keys.size } + + it 'triggers CounterJobWorker jobs' do + subject.perform + + expect(Analytics::UsageTrends::CounterJobWorker.jobs.count).to eq(expected_count) + end + end +end diff --git a/spec/workers/analytics/usage_trends/counter_job_worker_spec.rb b/spec/workers/analytics/usage_trends/counter_job_worker_spec.rb new file mode 100644 index 00000000000..9e4c82ee981 --- /dev/null +++ b/spec/workers/analytics/usage_trends/counter_job_worker_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Analytics::UsageTrends::CounterJobWorker do + let_it_be(:user_1) { create(:user) } + let_it_be(:user_2) { create(:user) } + + let(:users_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:users) } + let(:recorded_at) { Time.zone.now } + let(:job_args) { [users_measurement_identifier, user_1.id, user_2.id, recorded_at] } + + before do + allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false) + end + + include_examples 'an idempotent worker' do + it 'counts a scope and stores the result' do + subject + + measurement = Analytics::UsageTrends::Measurement.users.first + expect(measurement.recorded_at).to be_like_time(recorded_at) + expect(measurement.identifier).to eq('users') + expect(measurement.count).to eq(2) + end + end + + context 'when no records are in the database' do + let(:users_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:groups) } + + subject { described_class.new.perform(users_measurement_identifier, nil, nil, recorded_at) } + + it 'sets 0 as the count' do + subject + + measurement = Analytics::UsageTrends::Measurement.groups.first + expect(measurement.recorded_at).to be_like_time(recorded_at) + expect(measurement.identifier).to eq('groups') + expect(measurement.count).to eq(0) + end + end + + it 'does not raise error when inserting duplicated measurement' do + subject + + expect { subject }.not_to raise_error + end + + it 'does not insert anything when BatchCount returns error' do + allow(Gitlab::Database::BatchCount).to receive(:batch_count).and_return(Gitlab::Database::BatchCounter::FALLBACK) + + expect { subject }.not_to change { Analytics::UsageTrends::Measurement.count } + end + + context 'when pipelines_succeeded identifier is passed' do + let_it_be(:pipeline) { create(:ci_pipeline, :success) } + + let(:successful_pipelines_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:pipelines_succeeded) } + let(:job_args) { [successful_pipelines_measurement_identifier, pipeline.id, pipeline.id, recorded_at] } + + it 'counts successful pipelines' do + subject + + measurement = Analytics::UsageTrends::Measurement.pipelines_succeeded.first + expect(measurement.recorded_at).to be_like_time(recorded_at) + expect(measurement.identifier).to eq('pipelines_succeeded') + expect(measurement.count).to eq(1) + end + end +end diff --git a/spec/workers/emails_on_push_worker_spec.rb b/spec/workers/emails_on_push_worker_spec.rb index fac463b4dd4..6c37c422aed 100644 --- a/spec/workers/emails_on_push_worker_spec.rb +++ b/spec/workers/emails_on_push_worker_spec.rb @@ -97,7 +97,7 @@ RSpec.describe EmailsOnPushWorker, :mailer do end it "gracefully handles an input SMTP error" do - expect(ActionMailer::Base.deliveries.count).to eq(0) + expect(ActionMailer::Base.deliveries).to be_empty end end @@ -112,6 +112,16 @@ RSpec.describe EmailsOnPushWorker, :mailer do end end + context "with mixed-case recipient" do + let(:recipients) { user.email.upcase } + + it "retains the case" do + perform + + expect(email_recipients).to contain_exactly(recipients) + end + end + context "when the recipient addresses are a list of email addresses" do let(:recipients) do 1.upto(5).map { |i| user.email.sub('@', "+#{i}@") }.join("\n") @@ -120,7 +130,6 @@ RSpec.describe EmailsOnPushWorker, :mailer do it "sends the mail to each of the recipients" do perform - expect(ActionMailer::Base.deliveries.count).to eq(5) expect(email_recipients).to contain_exactly(*recipients.split) end @@ -132,13 +141,22 @@ RSpec.describe EmailsOnPushWorker, :mailer do end end + context "when recipients are invalid" do + let(:recipients) { "invalid\n\nrecipients" } + + it "ignores them" do + perform + + expect(ActionMailer::Base.deliveries).to be_empty + end + end + context "when the recipient addresses contains angle brackets and are separated by spaces" do let(:recipients) { "John Doe <johndoe@example.com> Jane Doe <janedoe@example.com>" } it "accepts emails separated by whitespace" do perform - expect(ActionMailer::Base.deliveries.count).to eq(2) expect(email_recipients).to contain_exactly("johndoe@example.com", "janedoe@example.com") end end @@ -149,7 +167,6 @@ RSpec.describe EmailsOnPushWorker, :mailer do it "accepts both kind of emails" do perform - expect(ActionMailer::Base.deliveries.count).to eq(2) expect(email_recipients).to contain_exactly("johndoe@example.com", "janedoe@example.com") end end @@ -160,10 +177,19 @@ RSpec.describe EmailsOnPushWorker, :mailer do it "accepts emails separated by newlines" do perform - expect(ActionMailer::Base.deliveries.count).to eq(2) expect(email_recipients).to contain_exactly("johndoe@example.com", "janedoe@example.com") end end + + context 'when the recipient addresses contains duplicates' do + let(:recipients) { 'non@dubplicate.com Duplic@te.com duplic@te.com Duplic@te.com duplic@Te.com' } + + it 'deduplicates recipients while treating the domain part as case-insensitive' do + perform + + expect(email_recipients).to contain_exactly('non@dubplicate.com', 'Duplic@te.com') + end + end end end end diff --git a/spec/workers/error_tracking_issue_link_worker_spec.rb b/spec/workers/error_tracking_issue_link_worker_spec.rb index 5be568c2dad..90e747c8788 100644 --- a/spec/workers/error_tracking_issue_link_worker_spec.rb +++ b/spec/workers/error_tracking_issue_link_worker_spec.rb @@ -20,7 +20,7 @@ RSpec.describe ErrorTrackingIssueLinkWorker do describe '#perform' do it 'creates a link between an issue and a Sentry issue in Sentry' do - expect_next_instance_of(Sentry::Client) do |client| + expect_next_instance_of(ErrorTracking::SentryClient) do |client| expect(client).to receive(:repos).with('sentry-org').and_return([repo]) expect(client) .to receive(:create_issue_link) @@ -33,8 +33,8 @@ RSpec.describe ErrorTrackingIssueLinkWorker do shared_examples_for 'makes no external API requests' do it 'takes no action' do - expect_any_instance_of(Sentry::Client).not_to receive(:repos) - expect_any_instance_of(Sentry::Client).not_to receive(:create_issue_link) + expect_any_instance_of(ErrorTracking::SentryClient).not_to receive(:repos) + expect_any_instance_of(ErrorTracking::SentryClient).not_to receive(:create_issue_link) expect(subject).to be nil end @@ -42,7 +42,7 @@ RSpec.describe ErrorTrackingIssueLinkWorker do shared_examples_for 'attempts to create a link via plugin' do it 'takes no action' do - expect_next_instance_of(Sentry::Client) do |client| + expect_next_instance_of(ErrorTracking::SentryClient) do |client| expect(client).to receive(:repos).with('sentry-org').and_return([repo]) expect(client) .to receive(:create_issue_link) @@ -98,8 +98,8 @@ RSpec.describe ErrorTrackingIssueLinkWorker do context 'when Sentry repos request errors' do it 'falls back to creating a link via plugin' do - expect_next_instance_of(Sentry::Client) do |client| - expect(client).to receive(:repos).with('sentry-org').and_raise(Sentry::Client::Error) + expect_next_instance_of(ErrorTracking::SentryClient) do |client| + expect(client).to receive(:repos).with('sentry-org').and_raise(ErrorTracking::SentryClient::Error) expect(client) .to receive(:create_issue_link) .with(nil, sentry_issue.sentry_issue_identifier, issue) diff --git a/spec/workers/expire_job_cache_worker_spec.rb b/spec/workers/expire_job_cache_worker_spec.rb index b4f8f56563b..95c54a762a4 100644 --- a/spec/workers/expire_job_cache_worker_spec.rb +++ b/spec/workers/expire_job_cache_worker_spec.rb @@ -13,7 +13,6 @@ RSpec.describe ExpireJobCacheWorker do include_examples 'an idempotent worker' do it 'invalidates Etag caching for the job path' do - pipeline_path = "/#{project.full_path}/-/pipelines/#{pipeline.id}.json" job_path = "/#{project.full_path}/builds/#{job.id}.json" spy_store = Gitlab::EtagCaching::Store.new @@ -22,13 +21,12 @@ RSpec.describe ExpireJobCacheWorker do expect(spy_store).to receive(:touch) .exactly(worker_exec_times).times - .with(pipeline_path) + .with(job_path) .and_call_original - expect(spy_store).to receive(:touch) + expect(ExpirePipelineCacheWorker).to receive(:perform_async) + .with(pipeline.id) .exactly(worker_exec_times).times - .with(job_path) - .and_call_original subject end diff --git a/spec/workers/expire_pipeline_cache_worker_spec.rb b/spec/workers/expire_pipeline_cache_worker_spec.rb index fb6ee67311c..a8c21aa9f83 100644 --- a/spec/workers/expire_pipeline_cache_worker_spec.rb +++ b/spec/workers/expire_pipeline_cache_worker_spec.rb @@ -25,15 +25,6 @@ RSpec.describe ExpirePipelineCacheWorker do subject.perform(617748) end - it "doesn't do anything if the pipeline cannot be cached" do - allow_any_instance_of(Ci::Pipeline).to receive(:cacheable?).and_return(false) - - expect_any_instance_of(Ci::ExpirePipelineCacheService).not_to receive(:execute) - expect_any_instance_of(Gitlab::EtagCaching::Store).not_to receive(:touch) - - subject.perform(pipeline.id) - end - it_behaves_like 'an idempotent worker' do let(:job_args) { [pipeline.id] } end diff --git a/spec/workers/jira_connect/sync_project_worker_spec.rb b/spec/workers/jira_connect/sync_project_worker_spec.rb index f7fa565d534..04cc3bec3af 100644 --- a/spec/workers/jira_connect/sync_project_worker_spec.rb +++ b/spec/workers/jira_connect/sync_project_worker_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe JiraConnect::SyncProjectWorker, factory_default: :keep do describe '#perform' do - let_it_be(:project) { create_default(:project) } + let_it_be(:project) { create_default(:project).freeze } let!(:mr_with_jira_title) { create(:merge_request, :unique_branches, title: 'TEST-123') } let!(:mr_with_jira_description) { create(:merge_request, :unique_branches, description: 'TEST-323') } let!(:mr_with_other_title) { create(:merge_request, :unique_branches) } diff --git a/spec/workers/merge_requests/delete_source_branch_worker_spec.rb b/spec/workers/merge_requests/delete_source_branch_worker_spec.rb new file mode 100644 index 00000000000..957adbbbd6e --- /dev/null +++ b/spec/workers/merge_requests/delete_source_branch_worker_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequests::DeleteSourceBranchWorker do + let_it_be(:merge_request) { create(:merge_request) } + let_it_be(:user) { create(:user) } + + let(:sha) { merge_request.source_branch_sha } + let(:worker) { described_class.new } + + describe '#perform' do + context 'with a non-existing merge request' do + it 'does nothing' do + expect(::Branches::DeleteService).not_to receive(:new) + expect(::MergeRequests::RetargetChainService).not_to receive(:new) + + worker.perform(non_existing_record_id, sha, user.id) + end + end + + context 'with a non-existing user' do + it 'does nothing' do + expect(::Branches::DeleteService).not_to receive(:new) + expect(::MergeRequests::RetargetChainService).not_to receive(:new) + + worker.perform(merge_request.id, sha, non_existing_record_id) + end + end + + context 'with existing user and merge request' do + it 'calls service to delete source branch' do + expect_next_instance_of(::Branches::DeleteService) do |instance| + expect(instance).to receive(:execute).with(merge_request.source_branch) + end + + worker.perform(merge_request.id, sha, user.id) + end + + it 'calls service to try retarget merge requests' do + expect_next_instance_of(::MergeRequests::RetargetChainService) do |instance| + expect(instance).to receive(:execute).with(merge_request) + end + + worker.perform(merge_request.id, sha, user.id) + end + + context 'source branch sha does not match' do + it 'does nothing' do + expect(::Branches::DeleteService).not_to receive(:new) + expect(::MergeRequests::RetargetChainService).not_to receive(:new) + + worker.perform(merge_request.id, 'new-source-branch-sha', user.id) + end + end + end + + it_behaves_like 'an idempotent worker' do + let(:merge_request) { create(:merge_request) } + let(:job_args) { [merge_request.id, sha, user.id] } + end + end +end diff --git a/spec/workers/merge_worker_spec.rb b/spec/workers/merge_worker_spec.rb index 97e8aeb616e..417e6edce96 100644 --- a/spec/workers/merge_worker_spec.rb +++ b/spec/workers/merge_worker_spec.rb @@ -14,7 +14,7 @@ RSpec.describe MergeWorker do source_project.repository.expire_branches_cache end - it 'clears cache of source repo after removing source branch' do + it 'clears cache of source repo after removing source branch', :sidekiq_inline do expect(source_project.repository.branch_names).to include('markdown') described_class.new.perform( diff --git a/spec/workers/namespaces/in_product_marketing_emails_worker_spec.rb b/spec/workers/namespaces/in_product_marketing_emails_worker_spec.rb index 722ecfc1dec..24143e8cf8a 100644 --- a/spec/workers/namespaces/in_product_marketing_emails_worker_spec.rb +++ b/spec/workers/namespaces/in_product_marketing_emails_worker_spec.rb @@ -3,25 +3,43 @@ require 'spec_helper' RSpec.describe Namespaces::InProductMarketingEmailsWorker, '#perform' do - context 'when the experiment is inactive' do + context 'when the application setting is enabled' do before do - stub_experiment(in_product_marketing_emails: false) + stub_application_setting(in_product_marketing_emails_enabled: true) end - it 'does not execute the in product marketing emails service' do - expect(Namespaces::InProductMarketingEmailsService).not_to receive(:send_for_all_tracks_and_intervals) + context 'when the experiment is inactive' do + before do + stub_experiment(in_product_marketing_emails: false) + end - subject.perform + it 'does not execute the in product marketing emails service' do + expect(Namespaces::InProductMarketingEmailsService).not_to receive(:send_for_all_tracks_and_intervals) + + subject.perform + end + end + + context 'when the experiment is active' do + before do + stub_experiment(in_product_marketing_emails: true) + end + + it 'calls the send_for_all_tracks_and_intervals method on the in product marketing emails service' do + expect(Namespaces::InProductMarketingEmailsService).to receive(:send_for_all_tracks_and_intervals) + + subject.perform + end end end - context 'when the experiment is active' do + context 'when the application setting is disabled' do before do - stub_experiment(in_product_marketing_emails: true) + stub_application_setting(in_product_marketing_emails_enabled: false) end - it 'calls the send_for_all_tracks_and_intervals method on the in product marketing emails service' do - expect(Namespaces::InProductMarketingEmailsService).to receive(:send_for_all_tracks_and_intervals) + it 'does not execute the in product marketing emails service' do + expect(Namespaces::InProductMarketingEmailsService).not_to receive(:send_for_all_tracks_and_intervals) subject.perform end diff --git a/spec/workers/namespaces/onboarding_issue_created_worker_spec.rb b/spec/workers/namespaces/onboarding_issue_created_worker_spec.rb new file mode 100644 index 00000000000..459e4f953d0 --- /dev/null +++ b/spec/workers/namespaces/onboarding_issue_created_worker_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Namespaces::OnboardingIssueCreatedWorker, '#perform' do + let_it_be(:issue) { create(:issue) } + let(:namespace) { issue.namespace } + + it_behaves_like 'records an onboarding progress action', :issue_created do + subject { described_class.new.perform(namespace.id) } + end + + it_behaves_like 'does not record an onboarding progress action' do + subject { described_class.new.perform(nil) } + end + + it_behaves_like 'an idempotent worker' do + let(:job_args) { [namespace.id] } + + it 'sets the onboarding progress action' do + OnboardingProgress.onboard(namespace) + + subject + + expect(OnboardingProgress.completed?(namespace, :issue_created)).to eq(true) + end + end +end diff --git a/spec/workers/namespaces/onboarding_progress_worker_spec.rb b/spec/workers/namespaces/onboarding_progress_worker_spec.rb new file mode 100644 index 00000000000..76ac078ddcf --- /dev/null +++ b/spec/workers/namespaces/onboarding_progress_worker_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Namespaces::OnboardingProgressWorker, '#perform' do + let_it_be(:namespace) { create(:namespace) } + let_it_be(:action) { 'git_pull' } + + it_behaves_like 'records an onboarding progress action', :git_pull do + include_examples 'an idempotent worker' do + subject { described_class.new.perform(namespace.id, action) } + end + end + + it_behaves_like 'does not record an onboarding progress action' do + subject { described_class.new.perform(namespace.id, nil) } + end + + it_behaves_like 'does not record an onboarding progress action' do + subject { described_class.new.perform(nil, action) } + end +end diff --git a/spec/workers/new_issue_worker_spec.rb b/spec/workers/new_issue_worker_spec.rb index 7cba3487603..ec129ad3380 100644 --- a/spec/workers/new_issue_worker_spec.rb +++ b/spec/workers/new_issue_worker_spec.rb @@ -38,21 +38,48 @@ RSpec.describe NewIssueWorker do end end - context 'when everything is ok' do - let_it_be(:user) { create_default(:user) } + context 'with a user' do let_it_be(:project) { create(:project, :public) } let_it_be(:mentioned) { create(:user) } + let_it_be(:user) { nil } let_it_be(:issue) { create(:issue, project: project, description: "issue for #{mentioned.to_reference}") } - it 'creates a new event record' do - expect { worker.perform(issue.id, user.id) }.to change { Event.count }.from(0).to(1) + shared_examples 'a new issue where the current_user cannot trigger notifications' do + it 'does not create a notification for the mentioned user' do + expect(Notify).not_to receive(:new_issue_email) + .with(mentioned.id, issue.id, NotificationReason::MENTIONED) + + expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: user.id, klass: issue.class, object_id: issue.id) + + worker.perform(issue.id, user.id) + end + end + + context 'when the new issue author is blocked' do + let_it_be(:user) { create_default(:user, :blocked) } + + it_behaves_like 'a new issue where the current_user cannot trigger notifications' end - it 'creates a notification for the mentioned user' do - expect(Notify).to receive(:new_issue_email).with(mentioned.id, issue.id, NotificationReason::MENTIONED) - .and_return(double(deliver_later: true)) + context 'when the new issue author is a ghost' do + let_it_be(:user) { create_default(:user, :ghost) } + + it_behaves_like 'a new issue where the current_user cannot trigger notifications' + end + + context 'when everything is ok' do + let_it_be(:user) { create_default(:user) } + + it 'creates a new event record' do + expect { worker.perform(issue.id, user.id) }.to change { Event.count }.from(0).to(1) + end + + it 'creates a notification for the mentioned user' do + expect(Notify).to receive(:new_issue_email).with(mentioned.id, issue.id, NotificationReason::MENTIONED) + .and_return(double(deliver_later: true)) - worker.perform(issue.id, user.id) + worker.perform(issue.id, user.id) + end end end end diff --git a/spec/workers/new_merge_request_worker_spec.rb b/spec/workers/new_merge_request_worker_spec.rb index 310fde4c7e1..0d64973b0fa 100644 --- a/spec/workers/new_merge_request_worker_spec.rb +++ b/spec/workers/new_merge_request_worker_spec.rb @@ -40,24 +40,51 @@ RSpec.describe NewMergeRequestWorker do end end - context 'when everything is ok' do + context 'with a user' do let(:project) { create(:project, :public) } let(:mentioned) { create(:user) } - let(:user) { create(:user) } + let(:user) { nil } let(:merge_request) do create(:merge_request, source_project: project, description: "mr for #{mentioned.to_reference}") end - it 'creates a new event record' do - expect { worker.perform(merge_request.id, user.id) }.to change { Event.count }.from(0).to(1) + shared_examples 'a new merge request where the author cannot trigger notifications' do + it 'does not create a notification for the mentioned user' do + expect(Notify).not_to receive(:new_merge_request_email) + .with(mentioned.id, merge_request.id, NotificationReason::MENTIONED) + + expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: user.id, klass: merge_request.class, object_id: merge_request.id) + + worker.perform(merge_request.id, user.id) + end + end + + context 'when the merge request author is blocked' do + let(:user) { create(:user, :blocked) } + + it_behaves_like 'a new merge request where the author cannot trigger notifications' end - it 'creates a notification for the mentioned user' do - expect(Notify).to receive(:new_merge_request_email) - .with(mentioned.id, merge_request.id, NotificationReason::MENTIONED) - .and_return(double(deliver_later: true)) + context 'when the merge request author is a ghost' do + let(:user) { create(:user, :ghost) } + + it_behaves_like 'a new merge request where the author cannot trigger notifications' + end + + context 'when everything is ok' do + let(:user) { create(:user) } + + it 'creates a new event record' do + expect { worker.perform(merge_request.id, user.id) }.to change { Event.count }.from(0).to(1) + end + + it 'creates a notification for the mentioned user' do + expect(Notify).to receive(:new_merge_request_email) + .with(mentioned.id, merge_request.id, NotificationReason::MENTIONED) + .and_return(double(deliver_later: true)) - worker.perform(merge_request.id, user.id) + worker.perform(merge_request.id, user.id) + end end end end diff --git a/spec/workers/packages/composer/cache_update_worker_spec.rb b/spec/workers/packages/composer/cache_update_worker_spec.rb new file mode 100644 index 00000000000..cc6b48c80eb --- /dev/null +++ b/spec/workers/packages/composer/cache_update_worker_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Composer::CacheUpdateWorker, type: :worker do + describe '#perform' do + let_it_be(:package_name) { 'sample-project' } + let_it_be(:json) { { 'name' => package_name } } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :custom_repo, files: { 'composer.json' => json.to_json }, group: group) } + let(:last_sha) { nil } + let!(:package) { create(:composer_package, :with_metadatum, project: project, name: package_name, version: '1.0.0', json: json) } + let(:job_args) { [project.id, package_name, last_sha] } + + subject { described_class.new.perform(*job_args) } + + before do + stub_composer_cache_object_storage + end + + include_examples 'an idempotent worker' do + context 'creating a package' do + it 'updates the cache' do + expect { subject }.to change { Packages::Composer::CacheFile.count }.by(1) + end + end + + context 'deleting a package' do + let!(:last_sha) do + Gitlab::Composer::Cache.new(project: project, name: package_name).execute + package.reload.composer_metadatum.version_cache_sha + end + + before do + package.destroy! + end + + it 'marks the file for deletion' do + expect { subject }.not_to change { Packages::Composer::CacheFile.count } + + cache_file = Packages::Composer::CacheFile.last + + expect(cache_file.reload.delete_at).not_to be_nil + end + end + end + end +end diff --git a/spec/workers/packages/maven/metadata/sync_worker_spec.rb b/spec/workers/packages/maven/metadata/sync_worker_spec.rb new file mode 100644 index 00000000000..7e0f3616491 --- /dev/null +++ b/spec/workers/packages/maven/metadata/sync_worker_spec.rb @@ -0,0 +1,253 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Maven::Metadata::SyncWorker, type: :worker do + let_it_be(:versionless_package_for_versions) { create(:maven_package, name: 'MyDummyMavenPkg', version: nil) } + let_it_be(:metadata_package_file) { create(:package_file, :xml, package: versionless_package_for_versions) } + + let(:versions) { %w[1.2 1.1 2.1 3.0-SNAPSHOT] } + let(:worker) { described_class.new } + + describe '#perform' do + let(:user) { create(:user) } + let(:project) { versionless_package_for_versions.project } + let(:package_name) { versionless_package_for_versions.name } + let(:role) { :maintainer } + let(:most_recent_metadata_file_for_versions) { versionless_package_for_versions.package_files.recent.with_file_name(Packages::Maven::Metadata.filename).first } + + before do + project.send("add_#{role}", user) + end + + subject { worker.perform(user.id, project.id, package_name) } + + context 'with a jar' do + context 'with a valid package name' do + before do + metadata_package_file.update!( + file: CarrierWaveStringFile.new_file( + file_content: versions_xml_content, + filename: 'maven-metadata.xml', + content_type: 'application/xml' + ) + ) + + versions.each do |version| + create(:maven_package, name: versionless_package_for_versions.name, version: version, project: versionless_package_for_versions.project) + end + end + + it_behaves_like 'an idempotent worker' do + let(:job_args) { [user.id, project.id, package_name] } + + it 'creates the updated metadata files', :aggregate_failures do + expect { subject }.to change { ::Packages::PackageFile.count }.by(5) + + most_recent_versions = versions_from(most_recent_metadata_file_for_versions.file.read) + expect(most_recent_versions.latest).to eq('3.0-SNAPSHOT') + expect(most_recent_versions.release).to eq('2.1') + expect(most_recent_versions.versions).to match_array(versions) + end + end + + it 'logs the message from the service' do + expect(worker).to receive(:log_extra_metadata_on_done).with(:message, 'New metadata package file created') + + subject + end + + context 'not in the passed project' do + let(:project) { create(:project) } + + it 'does not create the updated metadata files' do + expect { subject } + .to change { ::Packages::PackageFile.count }.by(0) + .and raise_error(described_class::SyncError, 'Non existing versionless package') + end + end + + context 'with a user with not enough permissions' do + let(:role) { :guest } + + it 'does not create the updated metadata files' do + expect { subject } + .to change { ::Packages::PackageFile.count }.by(0) + .and raise_error(described_class::SyncError, 'Not allowed') + end + end + end + end + + context 'with a maven plugin' do + let_it_be(:versionless_package_name_for_plugins) { versionless_package_for_versions.maven_metadatum.app_group.tr('.', '/') } + let_it_be(:versionless_package_for_versions) { create(:maven_package, name: "#{versionless_package_name_for_plugins}/one-maven-plugin", version: nil) } + let_it_be(:metadata_package_file) { create(:package_file, :xml, package: versionless_package_for_versions) } + + let_it_be(:versionless_package_for_plugins) { create(:maven_package, name: versionless_package_name_for_plugins, version: nil, project: versionless_package_for_versions.project) } + let_it_be(:metadata_package_file_for_plugins) { create(:package_file, :xml, package: versionless_package_for_plugins) } + + let_it_be(:addtional_maven_package_for_same_group_id) { create(:maven_package, name: "#{versionless_package_name_for_plugins}/maven-package", project: versionless_package_for_versions.project) } + + let(:plugins) { %w[one-maven-plugin three-maven-plugin] } + let(:most_recent_metadata_file_for_plugins) { versionless_package_for_plugins.package_files.recent.with_file_name(Packages::Maven::Metadata.filename).first } + + context 'with a valid package name' do + before do + versionless_package_for_versions.update!(name: package_name) + + metadata_package_file.update!( + file: CarrierWaveStringFile.new_file( + file_content: versions_xml_content, + filename: 'maven-metadata.xml', + content_type: 'application/xml' + ) + ) + + metadata_package_file_for_plugins.update!( + file: CarrierWaveStringFile.new_file( + file_content: plugins_xml_content, + filename: 'maven-metadata.xml', + content_type: 'application/xml' + ) + ) + + plugins.each do |plugin| + versions.each do |version| + pkg = create(:maven_package, name: "#{versionless_package_name_for_plugins}/#{plugin}", version: version, project: versionless_package_for_versions.project) + pkg.maven_metadatum.update!(app_name: plugin) + end + end + end + + it_behaves_like 'an idempotent worker' do + let(:job_args) { [user.id, project.id, package_name] } + + it 'creates the updated metadata files', :aggregate_failures do + expect { subject }.to change { ::Packages::PackageFile.count }.by(5 * 2) # the two xml files are updated + + most_recent_versions = versions_from(most_recent_metadata_file_for_versions.file.read) + expect(most_recent_versions.latest).to eq('3.0-SNAPSHOT') + expect(most_recent_versions.release).to eq('2.1') + expect(most_recent_versions.versions).to match_array(versions) + + plugins_from_xml = plugins_from(most_recent_metadata_file_for_plugins.file.read) + expect(plugins_from_xml).to match_array(plugins) + end + end + + it 'logs the message from the service' do + expect(worker).to receive(:log_extra_metadata_on_done).with(:message, 'New metadata package file created') + + subject + end + + context 'not in the passed project' do + let(:project) { create(:project) } + + it 'does not create the updated metadata files' do + expect { subject } + .to change { ::Packages::PackageFile.count }.by(0) + .and raise_error(described_class::SyncError, 'Non existing versionless package') + end + end + + context 'with a user with not enough permissions' do + let(:role) { :guest } + + it 'does not create the updated metadata files' do + expect { subject } + .to change { ::Packages::PackageFile.count }.by(0) + .and raise_error(described_class::SyncError, 'Not allowed') + end + end + end + end + + context 'with no package name' do + subject { worker.perform(user.id, project.id, nil) } + + it 'does not run' do + expect(::Packages::Maven::Metadata::SyncService).not_to receive(:new) + expect { subject }.not_to change { ::Packages::PackageFile.count } + end + end + + context 'with no user id' do + subject { worker.perform(nil, project.id, package_name) } + + it 'does not run' do + expect(::Packages::Maven::Metadata::SyncService).not_to receive(:new) + expect { subject }.not_to change { ::Packages::PackageFile.count } + end + end + + context 'with no project id' do + subject { worker.perform(user.id, nil, package_name) } + + it 'does not run' do + expect(::Packages::Maven::Metadata::SyncService).not_to receive(:new) + expect { subject }.not_to change { ::Packages::PackageFile.count } + end + end + end + + def versions_from(xml_content) + xml_doc = Nokogiri::XML(xml_content) + + OpenStruct.new( + release: xml_doc.xpath('//metadata/versioning/release').first.content, + latest: xml_doc.xpath('//metadata/versioning/latest').first.content, + versions: xml_doc.xpath('//metadata/versioning/versions/version').map(&:content) + ) + end + + def plugins_from(xml_content) + xml_doc = Nokogiri::XML(xml_content) + + xml_doc.xpath('//metadata/plugins/plugin/name').map(&:content) + end + + def versions_xml_content + Nokogiri::XML::Builder.new do |xml| + xml.metadata do + xml.groupId(versionless_package_for_versions.maven_metadatum.app_group) + xml.artifactId(versionless_package_for_versions.maven_metadatum.app_name) + xml.versioning do + xml.release('1.3') + xml.latest('1.3') + xml.lastUpdated('20210113130531') + xml.versions do + xml.version('1.1') + xml.version('1.2') + xml.version('1.3') + end + end + end + end.to_xml + end + + def plugins_xml_content + Nokogiri::XML::Builder.new do |xml| + xml.metadata do + xml.plugins do + xml.plugin do + xml.name('one-maven-plugin') + xml.prefix('one') + xml.artifactId('one-maven-plugin') + end + xml.plugin do + xml.name('two-maven-plugin') + xml.prefix('two') + xml.artifactId('two-maven-plugin') + end + xml.plugin do + xml.name('three-maven-plugin') + xml.prefix('three') + xml.artifactId('three-maven-plugin') + end + end + end + end.to_xml + end +end diff --git a/spec/workers/pages_update_configuration_worker_spec.rb b/spec/workers/pages_update_configuration_worker_spec.rb index 87bbff1a28b..ff3727646c7 100644 --- a/spec/workers/pages_update_configuration_worker_spec.rb +++ b/spec/workers/pages_update_configuration_worker_spec.rb @@ -2,9 +2,9 @@ require "spec_helper" RSpec.describe PagesUpdateConfigurationWorker do - describe "#perform" do - let_it_be(:project) { create(:project) } + let_it_be(:project) { create(:project) } + describe "#perform" do it "does not break if the project doesn't exist" do expect { subject.perform(-1) }.not_to raise_error end @@ -42,4 +42,22 @@ RSpec.describe PagesUpdateConfigurationWorker do end end end + + describe '#perform_async' do + it "calls the correct service", :sidekiq_inline do + expect_next_instance_of(Projects::UpdatePagesConfigurationService, project) do |service| + expect(service).to receive(:execute).and_return(status: :success) + end + + described_class.perform_async(project.id) + end + + it "doesn't schedule a worker if updates on legacy storage are disabled", :sidekiq_inline do + stub_feature_flags(pages_update_legacy_storage: false) + + expect(Projects::UpdatePagesConfigurationService).not_to receive(:new) + + described_class.perform_async(project.id) + end + end end diff --git a/spec/workers/personal_access_tokens/expiring_worker_spec.rb b/spec/workers/personal_access_tokens/expiring_worker_spec.rb index c8bdf02f4d3..7fa777b911a 100644 --- a/spec/workers/personal_access_tokens/expiring_worker_spec.rb +++ b/spec/workers/personal_access_tokens/expiring_worker_spec.rb @@ -7,18 +7,23 @@ RSpec.describe PersonalAccessTokens::ExpiringWorker, type: :worker do describe '#perform' do context 'when a token needs to be notified' do - let_it_be(:pat) { create(:personal_access_token, expires_at: 5.days.from_now) } + let_it_be(:user) { create(:user) } + let_it_be(:expiring_token) { create(:personal_access_token, user: user, expires_at: 5.days.from_now) } + let_it_be(:expiring_token2) { create(:personal_access_token, user: user, expires_at: 3.days.from_now) } + let_it_be(:notified_token) { create(:personal_access_token, user: user, expires_at: 5.days.from_now, expire_notification_delivered: true) } + let_it_be(:not_expiring_token) { create(:personal_access_token, user: user, expires_at: 1.month.from_now) } + let_it_be(:impersonation_token) { create(:personal_access_token, user: user, expires_at: 5.days.from_now, impersonation: true) } it 'uses notification service to send the email' do expect_next_instance_of(NotificationService) do |notification_service| - expect(notification_service).to receive(:access_token_about_to_expire).with(pat.user) + expect(notification_service).to receive(:access_token_about_to_expire).with(user, match_array([expiring_token.name, expiring_token2.name])) end worker.perform end it 'marks the notification as delivered' do - expect { worker.perform }.to change { pat.reload.expire_notification_delivered }.from(false).to(true) + expect { worker.perform }.to change { expiring_token.reload.expire_notification_delivered }.from(false).to(true) end end @@ -27,7 +32,7 @@ RSpec.describe PersonalAccessTokens::ExpiringWorker, type: :worker do it "doesn't use notification service to send the email" do expect_next_instance_of(NotificationService) do |notification_service| - expect(notification_service).not_to receive(:access_token_about_to_expire).with(pat.user) + expect(notification_service).not_to receive(:access_token_about_to_expire).with(pat.user, [pat.name]) end worker.perform @@ -43,7 +48,7 @@ RSpec.describe PersonalAccessTokens::ExpiringWorker, type: :worker do it "doesn't use notification service to send the email" do expect_next_instance_of(NotificationService) do |notification_service| - expect(notification_service).not_to receive(:access_token_about_to_expire).with(pat.user) + expect(notification_service).not_to receive(:access_token_about_to_expire).with(pat.user, [pat.name]) end worker.perform diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index aaae0988602..be501318920 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -93,6 +93,29 @@ RSpec.describe PostReceive do perform end + + it 'tracks an event for the empty_repo_upload experiment', :snowplow do + allow_next_instance_of(ApplicationExperiment) do |e| + allow(e).to receive(:should_track?).and_return(true) + allow(e).to receive(:track_initial_writes) + end + + perform + + expect_snowplow_event(category: 'empty_repo_upload', action: 'initial_write', context: [{ schema: 'iglu:com.gitlab/gitlab_experiment/jsonschema/0-3-0', data: anything }]) + end + + it 'does not track an event for the empty_repo_upload experiment when project is not empty', :snowplow do + allow(empty_project).to receive(:empty_repo?).and_return(false) + allow_next_instance_of(ApplicationExperiment) do |e| + allow(e).to receive(:should_track?).and_return(true) + allow(e).to receive(:track_initial_writes) + end + + perform + + expect_no_snowplow_event + end end shared_examples 'not updating remote mirrors' do @@ -159,7 +182,7 @@ RSpec.describe PostReceive do end it 'expires the status cache' do - expect(project.repository).to receive(:empty?).and_return(true) + expect(project.repository).to receive(:empty?).at_least(:once).and_return(true) expect(project.repository).to receive(:expire_status_cache) perform diff --git a/spec/workers/project_schedule_bulk_repository_shard_moves_worker_spec.rb b/spec/workers/project_schedule_bulk_repository_shard_moves_worker_spec.rb index fb762593d75..f284e1ab8c6 100644 --- a/spec/workers/project_schedule_bulk_repository_shard_moves_worker_spec.rb +++ b/spec/workers/project_schedule_bulk_repository_shard_moves_worker_spec.rb @@ -6,7 +6,7 @@ RSpec.describe ProjectScheduleBulkRepositoryShardMovesWorker do it_behaves_like 'schedules bulk repository shard moves' do let_it_be_with_reload(:container) { create(:project, :repository).tap { |project| project.track_project_repository } } - let(:move_service_klass) { ProjectRepositoryStorageMove } - let(:worker_klass) { ProjectUpdateRepositoryStorageWorker } + let(:move_service_klass) { Projects::RepositoryStorageMove } + let(:worker_klass) { Projects::UpdateRepositoryStorageWorker } end end diff --git a/spec/workers/project_update_repository_storage_worker_spec.rb b/spec/workers/project_update_repository_storage_worker_spec.rb index 490f1f5a2ad..6924e8a93a3 100644 --- a/spec/workers/project_update_repository_storage_worker_spec.rb +++ b/spec/workers/project_update_repository_storage_worker_spec.rb @@ -10,6 +10,6 @@ RSpec.describe ProjectUpdateRepositoryStorageWorker do let_it_be(:repository_storage_move) { create(:project_repository_storage_move) } let(:service_klass) { Projects::UpdateRepositoryStorageService } - let(:repository_storage_move_klass) { ProjectRepositoryStorageMove } + let(:repository_storage_move_klass) { Projects::RepositoryStorageMove } end end diff --git a/spec/workers/projects/schedule_bulk_repository_shard_moves_worker_spec.rb b/spec/workers/projects/schedule_bulk_repository_shard_moves_worker_spec.rb new file mode 100644 index 00000000000..24957a35b72 --- /dev/null +++ b/spec/workers/projects/schedule_bulk_repository_shard_moves_worker_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::ScheduleBulkRepositoryShardMovesWorker do + it_behaves_like 'schedules bulk repository shard moves' do + let_it_be_with_reload(:container) { create(:project, :repository).tap { |project| project.track_project_repository } } + + let(:move_service_klass) { Projects::RepositoryStorageMove } + let(:worker_klass) { Projects::UpdateRepositoryStorageWorker } + end +end diff --git a/spec/workers/projects/update_repository_storage_worker_spec.rb b/spec/workers/projects/update_repository_storage_worker_spec.rb new file mode 100644 index 00000000000..7570d706325 --- /dev/null +++ b/spec/workers/projects/update_repository_storage_worker_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::UpdateRepositoryStorageWorker do + subject { described_class.new } + + it_behaves_like 'an update storage move worker' do + let_it_be_with_refind(:container) { create(:project, :repository) } + let_it_be(:repository_storage_move) { create(:project_repository_storage_move) } + + let(:service_klass) { Projects::UpdateRepositoryStorageService } + let(:repository_storage_move_klass) { Projects::RepositoryStorageMove } + end +end diff --git a/spec/workers/purge_dependency_proxy_cache_worker_spec.rb b/spec/workers/purge_dependency_proxy_cache_worker_spec.rb index 8379b11af8f..53f8d1bf5ba 100644 --- a/spec/workers/purge_dependency_proxy_cache_worker_spec.rb +++ b/spec/workers/purge_dependency_proxy_cache_worker_spec.rb @@ -26,19 +26,25 @@ RSpec.describe PurgeDependencyProxyCacheWorker do end context 'an admin user' do - include_examples 'an idempotent worker' do - let(:job_args) { [user.id, group_id] } + context 'when admin mode is enabled', :enable_admin_mode do + include_examples 'an idempotent worker' do + let(:job_args) { [user.id, group_id] } - it 'deletes the blobs and returns ok', :aggregate_failures do - expect(group.dependency_proxy_blobs.size).to eq(1) - expect(group.dependency_proxy_manifests.size).to eq(1) + it 'deletes the blobs and returns ok', :aggregate_failures do + expect(group.dependency_proxy_blobs.size).to eq(1) + expect(group.dependency_proxy_manifests.size).to eq(1) - subject + subject - expect(group.dependency_proxy_blobs.size).to eq(0) - expect(group.dependency_proxy_manifests.size).to eq(0) + expect(group.dependency_proxy_blobs.size).to eq(0) + expect(group.dependency_proxy_manifests.size).to eq(0) + end end end + + context 'when admin mode is disabled' do + it_behaves_like 'returns nil' + end end context 'a non-admin user' do diff --git a/spec/workers/snippet_schedule_bulk_repository_shard_moves_worker_spec.rb b/spec/workers/snippet_schedule_bulk_repository_shard_moves_worker_spec.rb index 3a09b6ce449..a5f1c6b7b3d 100644 --- a/spec/workers/snippet_schedule_bulk_repository_shard_moves_worker_spec.rb +++ b/spec/workers/snippet_schedule_bulk_repository_shard_moves_worker_spec.rb @@ -6,7 +6,7 @@ RSpec.describe SnippetScheduleBulkRepositoryShardMovesWorker do it_behaves_like 'schedules bulk repository shard moves' do let_it_be_with_reload(:container) { create(:snippet, :repository).tap { |snippet| snippet.create_repository } } - let(:move_service_klass) { SnippetRepositoryStorageMove } - let(:worker_klass) { SnippetUpdateRepositoryStorageWorker } + let(:move_service_klass) { Snippets::RepositoryStorageMove } + let(:worker_klass) { Snippets::UpdateRepositoryStorageWorker } end end diff --git a/spec/workers/snippet_update_repository_storage_worker_spec.rb b/spec/workers/snippet_update_repository_storage_worker_spec.rb index a48abe4abf7..205cb2e432f 100644 --- a/spec/workers/snippet_update_repository_storage_worker_spec.rb +++ b/spec/workers/snippet_update_repository_storage_worker_spec.rb @@ -10,6 +10,6 @@ RSpec.describe SnippetUpdateRepositoryStorageWorker do let_it_be(:repository_storage_move) { create(:snippet_repository_storage_move) } let(:service_klass) { Snippets::UpdateRepositoryStorageService } - let(:repository_storage_move_klass) { SnippetRepositoryStorageMove } + let(:repository_storage_move_klass) { Snippets::RepositoryStorageMove } end end diff --git a/spec/workers/snippets/schedule_bulk_repository_shard_moves_worker_spec.rb b/spec/workers/snippets/schedule_bulk_repository_shard_moves_worker_spec.rb new file mode 100644 index 00000000000..be7d8ebe2d3 --- /dev/null +++ b/spec/workers/snippets/schedule_bulk_repository_shard_moves_worker_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Snippets::ScheduleBulkRepositoryShardMovesWorker do + it_behaves_like 'schedules bulk repository shard moves' do + let_it_be_with_reload(:container) { create(:snippet, :repository).tap { |snippet| snippet.create_repository } } + + let(:move_service_klass) { Snippets::RepositoryStorageMove } + let(:worker_klass) { Snippets::UpdateRepositoryStorageWorker } + end +end diff --git a/spec/workers/snippets/update_repository_storage_worker_spec.rb b/spec/workers/snippets/update_repository_storage_worker_spec.rb new file mode 100644 index 00000000000..38e9012e9c5 --- /dev/null +++ b/spec/workers/snippets/update_repository_storage_worker_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Snippets::UpdateRepositoryStorageWorker do + subject { described_class.new } + + it_behaves_like 'an update storage move worker' do + let_it_be_with_refind(:container) { create(:snippet, :repository) } + let_it_be(:repository_storage_move) { create(:snippet_repository_storage_move) } + + let(:service_klass) { Snippets::UpdateRepositoryStorageService } + let(:repository_storage_move_klass) { Snippets::RepositoryStorageMove } + end +end |