diff options
Diffstat (limited to 'spec')
1206 files changed, 29993 insertions, 14770 deletions
diff --git a/spec/config/grape_entity_patch_spec.rb b/spec/config/grape_entity_patch_spec.rb deleted file mode 100644 index 7334f270ca1..00000000000 --- a/spec/config/grape_entity_patch_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Grape::Entity patch' do - let(:entity_class) { Class.new(Grape::Entity) } - - describe 'NameError in block exposure with argument' do - subject(:represent) { entity_class.represent({}, serializable: true) } - - before do - entity_class.expose :raise_no_method_error do |_| - foo - end - end - - it 'propagates the error to the caller' do - expect { represent }.to raise_error(NameError) - end - end -end diff --git a/spec/controllers/admin/instance_review_controller_spec.rb b/spec/controllers/admin/instance_review_controller_spec.rb index d15894eeb5d..898cd30cdca 100644 --- a/spec/controllers/admin/instance_review_controller_spec.rb +++ b/spec/controllers/admin/instance_review_controller_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Admin::InstanceReviewController do include UsageDataHelpers let(:admin) { create(:admin) } - let(:subscriptions_url) { ::Gitlab::SubscriptionPortal::SUBSCRIPTIONS_URL } + let(:subscriptions_instance_review_url) { Gitlab::SubscriptionPortal.subscriptions_instance_review_url } before do sign_in(admin) @@ -44,7 +44,7 @@ RSpec.describe Admin::InstanceReviewController do notes_count: 0 } }.to_query - expect(response).to redirect_to("#{subscriptions_url}/instance_review?#{params}") + expect(response).to redirect_to("#{subscriptions_instance_review_url}?#{params}") end end @@ -61,7 +61,7 @@ RSpec.describe Admin::InstanceReviewController do version: ::Gitlab::VERSION } }.to_query - expect(response).to redirect_to("#{subscriptions_url}/instance_review?#{params}") + expect(response).to redirect_to("#{subscriptions_instance_review_url}?#{params}") end end end diff --git a/spec/controllers/admin/serverless/domains_controller_spec.rb b/spec/controllers/admin/serverless/domains_controller_spec.rb deleted file mode 100644 index e7503fb37fa..00000000000 --- a/spec/controllers/admin/serverless/domains_controller_spec.rb +++ /dev/null @@ -1,370 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Admin::Serverless::DomainsController do - let(:admin) { create(:admin) } - let(:user) { create(:user) } - - describe '#index' do - context 'non-admin user' do - before do - sign_in(user) - end - - it 'responds with 404' do - get :index - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'admin user' do - before do - create(:pages_domain) - sign_in(admin) - end - - context 'with serverless_domain feature disabled' do - before do - stub_feature_flags(serverless_domain: false) - end - - it 'responds with 404' do - get :index - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'when instance-level serverless domain exists' do - let!(:serverless_domain) { create(:pages_domain, :instance_serverless) } - - it 'loads the instance serverless domain' do - get :index - - expect(assigns(:domain).id).to eq(serverless_domain.id) - end - end - - context 'when domain does not exist' do - it 'initializes an instance serverless domain' do - get :index - - domain = assigns(:domain) - - expect(domain.persisted?).to eq(false) - expect(domain.wildcard).to eq(true) - expect(domain.scope).to eq('instance') - expect(domain.usage).to eq('serverless') - end - end - end - end - - describe '#create' do - let(:create_params) do - sample_domain = build(:pages_domain) - - { - domain: 'serverless.gitlab.io', - user_provided_certificate: sample_domain.certificate, - user_provided_key: sample_domain.key - } - end - - context 'non-admin user' do - before do - sign_in(user) - end - - it 'responds with 404' do - post :create, params: { pages_domain: create_params } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'admin user' do - before do - sign_in(admin) - end - - context 'with serverless_domain feature disabled' do - before do - stub_feature_flags(serverless_domain: false) - end - - it 'responds with 404' do - post :create, params: { pages_domain: create_params } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'when an instance-level serverless domain exists' do - let!(:serverless_domain) { create(:pages_domain, :instance_serverless) } - - it 'does not create a new domain' do - expect { post :create, params: { pages_domain: create_params } }.not_to change { PagesDomain.instance_serverless.count } - end - - it 'redirects to index' do - post :create, params: { pages_domain: create_params } - - expect(response).to redirect_to admin_serverless_domains_path - expect(flash[:notice]).to include('An instance-level serverless domain already exists.') - end - end - - context 'when an instance-level serverless domain does not exist' do - it 'creates an instance serverless domain with the provided attributes' do - expect { post :create, params: { pages_domain: create_params } }.to change { PagesDomain.instance_serverless.count }.by(1) - - domain = PagesDomain.instance_serverless.first - expect(domain.domain).to eq(create_params[:domain]) - expect(domain.certificate).to eq(create_params[:user_provided_certificate]) - expect(domain.key).to eq(create_params[:user_provided_key]) - expect(domain.wildcard).to eq(true) - expect(domain.scope).to eq('instance') - expect(domain.usage).to eq('serverless') - end - - it 'redirects to index' do - post :create, params: { pages_domain: create_params } - - expect(response).to redirect_to admin_serverless_domains_path - expect(flash[:notice]).to include('Domain was successfully created.') - end - end - - context 'when there are errors' do - it 'renders index view' do - post :create, params: { pages_domain: { foo: 'bar' } } - - expect(assigns(:domain).errors.size).to be > 0 - expect(response).to render_template('index') - end - end - end - end - - describe '#update' do - let(:domain) { create(:pages_domain, :instance_serverless) } - - let(:update_params) do - sample_domain = build(:pages_domain) - - { - user_provided_certificate: sample_domain.certificate, - user_provided_key: sample_domain.key - } - end - - context 'non-admin user' do - before do - sign_in(user) - end - - it 'responds with 404' do - put :update, params: { id: domain.id, pages_domain: update_params } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'admin user' do - before do - sign_in(admin) - end - - context 'with serverless_domain feature disabled' do - before do - stub_feature_flags(serverless_domain: false) - end - - it 'responds with 404' do - put :update, params: { id: domain.id, pages_domain: update_params } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'when domain exists' do - it 'updates the domain with the provided attributes' do - new_certificate = build(:pages_domain, :ecdsa).certificate - new_key = build(:pages_domain, :ecdsa).key - - put :update, params: { id: domain.id, pages_domain: { user_provided_certificate: new_certificate, user_provided_key: new_key } } - - domain.reload - - expect(domain.certificate).to eq(new_certificate) - expect(domain.key).to eq(new_key) - end - - it 'does not update the domain name' do - put :update, params: { id: domain.id, pages_domain: { domain: 'new.com' } } - - expect(domain.reload.domain).not_to eq('new.com') - end - - it 'redirects to index' do - put :update, params: { id: domain.id, pages_domain: update_params } - - expect(response).to redirect_to admin_serverless_domains_path - expect(flash[:notice]).to include('Domain was successfully updated.') - end - end - - context 'when domain does not exist' do - it 'returns 404' do - put :update, params: { id: 0, pages_domain: update_params } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'when there are errors' do - it 'renders index view' do - put :update, params: { id: domain.id, pages_domain: { user_provided_certificate: 'bad certificate' } } - - expect(assigns(:domain).errors.size).to be > 0 - expect(response).to render_template('index') - end - end - end - end - - describe '#verify' do - let(:domain) { create(:pages_domain, :instance_serverless) } - - context 'non-admin user' do - before do - sign_in(user) - end - - it 'responds with 404' do - post :verify, params: { id: domain.id } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'admin user' do - before do - sign_in(admin) - end - - def stub_service - service = double(:service) - - expect(VerifyPagesDomainService).to receive(:new).with(domain).and_return(service) - - service - end - - context 'with serverless_domain feature disabled' do - before do - stub_feature_flags(serverless_domain: false) - end - - it 'responds with 404' do - post :verify, params: { id: domain.id } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - it 'handles verification success' do - expect(stub_service).to receive(:execute).and_return(status: :success) - - post :verify, params: { id: domain.id } - - expect(response).to redirect_to admin_serverless_domains_path - expect(flash[:notice]).to eq('Successfully verified domain ownership') - end - - it 'handles verification failure' do - expect(stub_service).to receive(:execute).and_return(status: :failed) - - post :verify, params: { id: domain.id } - - expect(response).to redirect_to admin_serverless_domains_path - expect(flash[:alert]).to eq('Failed to verify domain ownership') - end - end - end - - describe '#destroy' do - let!(:domain) { create(:pages_domain, :instance_serverless) } - - context 'non-admin user' do - before do - sign_in(user) - end - - it 'responds with 404' do - delete :destroy, params: { id: domain.id } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'admin user' do - before do - sign_in(admin) - end - - context 'with serverless_domain feature disabled' do - before do - stub_feature_flags(serverless_domain: false) - end - - it 'responds with 404' do - delete :destroy, params: { id: domain.id } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'when domain exists' do - context 'and is not associated to any clusters' do - it 'deletes the domain' do - expect { delete :destroy, params: { id: domain.id } } - .to change { PagesDomain.count }.from(1).to(0) - - expect(response).to have_gitlab_http_status(:found) - expect(flash[:notice]).to include('Domain was successfully deleted.') - end - end - - context 'and is associated to any clusters' do - before do - create(:serverless_domain_cluster, pages_domain: domain) - end - - it 'does not delete the domain' do - expect { delete :destroy, params: { id: domain.id } } - .not_to change { PagesDomain.count } - - expect(response).to have_gitlab_http_status(:conflict) - expect(flash[:notice]).to include('Domain cannot be deleted while associated to one or more clusters.') - end - end - end - - context 'when domain does not exist' do - before do - domain.destroy! - end - - it 'responds with 404' do - delete :destroy, params: { id: domain.id } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - end - end -end diff --git a/spec/controllers/admin/topics/avatars_controller_spec.rb b/spec/controllers/admin/topics/avatars_controller_spec.rb new file mode 100644 index 00000000000..7edc0e0c497 --- /dev/null +++ b/spec/controllers/admin/topics/avatars_controller_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Admin::Topics::AvatarsController do + let(:user) { create(:admin) } + let(:topic) { create(:topic, avatar: fixture_file_upload("spec/fixtures/dk.png")) } + + before do + sign_in(user) + controller.instance_variable_set(:@topic, topic) + end + + it 'removes avatar from DB by calling destroy' do + delete :destroy, params: { topic_id: topic.id } + @topic = assigns(:topic) + expect(@topic.avatar.present?).to be_falsey + expect(@topic).to be_valid + end +end diff --git a/spec/controllers/admin/topics_controller_spec.rb b/spec/controllers/admin/topics_controller_spec.rb new file mode 100644 index 00000000000..6d66cb43338 --- /dev/null +++ b/spec/controllers/admin/topics_controller_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Admin::TopicsController do + let_it_be(:topic) { create(:topic, name: 'topic') } + let_it_be(:admin) { create(:admin) } + let_it_be(:user) { create(:user) } + + before do + sign_in(admin) + end + + describe 'GET #index' do + it 'renders the template' do + get :index + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template('index') + end + + context 'as a normal user' do + before do + sign_in(user) + end + + it 'renders a 404 error' do + get :index + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + describe 'GET #new' do + it 'renders the template' do + get :new + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template('new') + end + + context 'as a normal user' do + before do + sign_in(user) + end + + it 'renders a 404 error' do + get :new + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + describe 'GET #edit' do + it 'renders the template' do + get :edit, params: { id: topic.id } + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template('edit') + end + + context 'as a normal user' do + before do + sign_in(user) + end + + it 'renders a 404 error' do + get :edit, params: { id: topic.id } + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + describe 'POST #create' do + it 'creates topic' do + expect do + post :create, params: { projects_topic: { name: 'test' } } + end.to change { Projects::Topic.count }.by(1) + end + + it 'shows error message for invalid topic' do + post :create, params: { projects_topic: { name: nil } } + + errors = assigns[:topic].errors + expect(errors).to contain_exactly(errors.full_message(:name, I18n.t('errors.messages.blank'))) + end + + context 'as a normal user' do + before do + sign_in(user) + end + + it 'renders a 404 error' do + post :create, params: { projects_topic: { name: 'test' } } + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + describe 'PUT #update' do + it 'updates topic' do + put :update, params: { id: topic.id, projects_topic: { name: 'test' } } + + expect(response).to redirect_to(edit_admin_topic_path(topic)) + expect(topic.reload.name).to eq('test') + end + + it 'shows error message for invalid topic' do + put :update, params: { id: topic.id, projects_topic: { name: nil } } + + errors = assigns[:topic].errors + expect(errors).to contain_exactly(errors.full_message(:name, I18n.t('errors.messages.blank'))) + end + + context 'as a normal user' do + before do + sign_in(user) + end + + it 'renders a 404 error' do + put :update, params: { id: topic.id, projects_topic: { name: 'test' } } + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end +end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 218aa04dd3f..e9a49319f21 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -704,7 +704,7 @@ RSpec.describe ApplicationController do get :index - expect(response.headers['Cache-Control']).to eq 'no-store' + expect(response.headers['Cache-Control']).to eq 'private, no-store' expect(response.headers['Pragma']).to eq 'no-cache' end @@ -740,7 +740,7 @@ RSpec.describe ApplicationController do it 'sets no-cache headers', :aggregate_failures do subject - expect(response.headers['Cache-Control']).to eq 'no-store' + expect(response.headers['Cache-Control']).to eq 'private, no-store' expect(response.headers['Pragma']).to eq 'no-cache' expect(response.headers['Expires']).to eq 'Fri, 01 Jan 1990 00:00:00 GMT' end @@ -967,6 +967,14 @@ RSpec.describe ApplicationController do end end + describe '.endpoint_id_for_action' do + controller(described_class) { } + + it 'returns an expected endpoint id' do + expect(controller.class.endpoint_id_for_action('hello')).to eq('AnonymousController#hello') + end + end + describe '#current_user' do controller(described_class) do def index; end diff --git a/spec/controllers/boards/issues_controller_spec.rb b/spec/controllers/boards/issues_controller_spec.rb index cc60ab16d2e..b2200050e41 100644 --- a/spec/controllers/boards/issues_controller_spec.rb +++ b/spec/controllers/boards/issues_controller_spec.rb @@ -116,7 +116,7 @@ RSpec.describe Boards::IssuesController do it 'does not query issues table more than once' do recorder = ActiveRecord::QueryRecorder.new { list_issues(user: user, board: board, list: list1) } - query_count = recorder.occurrences.select { |query,| query.start_with?('SELECT issues.*') }.each_value.first + query_count = recorder.occurrences.select { |query,| query.match?(/FROM "?issues"?/) }.each_value.first expect(query_count).to eq(1) end diff --git a/spec/controllers/concerns/group_tree_spec.rb b/spec/controllers/concerns/group_tree_spec.rb index a0707688e54..e808f1caa6e 100644 --- a/spec/controllers/concerns/group_tree_spec.rb +++ b/spec/controllers/concerns/group_tree_spec.rb @@ -21,82 +21,94 @@ RSpec.describe GroupTree do end describe 'GET #index' do - it 'filters groups' do - other_group = create(:group, name: 'filter') - other_group.add_owner(user) + shared_examples 'returns filtered groups' do + it 'filters groups' do + other_group = create(:group, name: 'filter') + other_group.add_owner(user) - get :index, params: { filter: 'filt' }, format: :json + get :index, params: { filter: 'filt' }, format: :json - expect(assigns(:groups)).to contain_exactly(other_group) - end + expect(assigns(:groups)).to contain_exactly(other_group) + end - context 'for subgroups' do - it 'only renders root groups when no parent was given' do - create(:group, :public, parent: group) + context 'for subgroups' do + it 'only renders root groups when no parent was given' do + create(:group, :public, parent: group) - get :index, format: :json + get :index, format: :json - expect(assigns(:groups)).to contain_exactly(group) - end + expect(assigns(:groups)).to contain_exactly(group) + end - it 'contains only the subgroup when a parent was given' do - subgroup = create(:group, :public, parent: group) + it 'contains only the subgroup when a parent was given' do + subgroup = create(:group, :public, parent: group) - get :index, params: { parent_id: group.id }, format: :json + get :index, params: { parent_id: group.id }, format: :json - expect(assigns(:groups)).to contain_exactly(subgroup) - end + expect(assigns(:groups)).to contain_exactly(subgroup) + end - it 'allows filtering for subgroups and includes the parents for rendering' do - subgroup = create(:group, :public, parent: group, name: 'filter') + it 'allows filtering for subgroups and includes the parents for rendering' do + subgroup = create(:group, :public, parent: group, name: 'filter') - get :index, params: { filter: 'filt' }, format: :json + get :index, params: { filter: 'filt' }, format: :json - expect(assigns(:groups)).to contain_exactly(group, subgroup) - end + expect(assigns(:groups)).to contain_exactly(group, subgroup) + end - it 'does not include groups the user does not have access to' do - parent = create(:group, :private) - subgroup = create(:group, :private, parent: parent, name: 'filter') - subgroup.add_developer(user) - _other_subgroup = create(:group, :private, parent: parent, name: 'filte') + it 'does not include groups the user does not have access to' do + parent = create(:group, :private) + subgroup = create(:group, :private, parent: parent, name: 'filter') + subgroup.add_developer(user) + _other_subgroup = create(:group, :private, parent: parent, name: 'filte') - get :index, params: { filter: 'filt' }, format: :json + get :index, params: { filter: 'filt' }, format: :json - expect(assigns(:groups)).to contain_exactly(parent, subgroup) - end + expect(assigns(:groups)).to contain_exactly(parent, subgroup) + end - it 'preloads parents regardless of pagination' do - allow(Kaminari.config).to receive(:default_per_page).and_return(1) - group = create(:group, :public) - subgroup = create(:group, :public, parent: group) - search_result = create(:group, :public, name: 'result', parent: subgroup) + it 'preloads parents regardless of pagination' do + allow(Kaminari.config).to receive(:default_per_page).and_return(1) + group = create(:group, :public) + subgroup = create(:group, :public, parent: group) + search_result = create(:group, :public, name: 'result', parent: subgroup) - get :index, params: { filter: 'resu' }, format: :json + get :index, params: { filter: 'resu' }, format: :json - expect(assigns(:groups)).to contain_exactly(group, subgroup, search_result) + expect(assigns(:groups)).to contain_exactly(group, subgroup, search_result) + end end - end - context 'json content' do - it 'shows groups as json' do - get :index, format: :json + context 'json content' do + it 'shows groups as json' do + get :index, format: :json - expect(json_response.first['id']).to eq(group.id) - end + expect(json_response.first['id']).to eq(group.id) + end - context 'nested groups' do - it 'expands the tree when filtering' do - subgroup = create(:group, :public, parent: group, name: 'filter') + context 'nested groups' do + it 'expands the tree when filtering' do + subgroup = create(:group, :public, parent: group, name: 'filter') - get :index, params: { filter: 'filt' }, format: :json + get :index, params: { filter: 'filt' }, format: :json - children_response = json_response.first['children'] + children_response = json_response.first['children'] - expect(json_response.first['id']).to eq(group.id) - expect(children_response.first['id']).to eq(subgroup.id) + expect(json_response.first['id']).to eq(group.id) + expect(children_response.first['id']).to eq(subgroup.id) + end end end end + + it_behaves_like 'returns filtered groups' + + context 'when feature flag :linear_group_tree_ancestor_scopes is disabled' do + before do + stub_feature_flags(linear_group_tree_ancestor_scopes: false) + end + + it_behaves_like 'returns filtered groups' + end end end diff --git a/spec/controllers/dashboard/milestones_controller_spec.rb b/spec/controllers/dashboard/milestones_controller_spec.rb index 899aa7a41c1..2d41bc431ec 100644 --- a/spec/controllers/dashboard/milestones_controller_spec.rb +++ b/spec/controllers/dashboard/milestones_controller_spec.rb @@ -65,11 +65,12 @@ RSpec.describe Dashboard::MilestonesController do expect(response.body).not_to include(project_milestone.title) end - it 'shows counts of open and closed group and project milestones to which the user belongs to' do + it 'shows counts of open/closed/all group and project milestones to which the user belongs to' do get :index - expect(response.body).to include("Open\n<span class=\"badge badge-pill\">2</span>") - expect(response.body).to include("Closed\n<span class=\"badge badge-pill\">2</span>") + expect(response.body).to have_content('Open 2') + expect(response.body).to have_content('Closed 2') + expect(response.body).to have_content('All 4') end context 'external authorization' do diff --git a/spec/controllers/every_controller_spec.rb b/spec/controllers/every_controller_spec.rb index a1c377eff76..902872b6e92 100644 --- a/spec/controllers/every_controller_spec.rb +++ b/spec/controllers/every_controller_spec.rb @@ -1,24 +1,14 @@ # frozen_string_literal: true require 'spec_helper' - RSpec.describe "Every controller" do context "feature categories" do let_it_be(:feature_categories) do - YAML.load_file(Rails.root.join('config', 'feature_categories.yml')).map(&:to_sym).to_set + Gitlab::FeatureCategories.default.categories.map(&:to_sym).to_set end let_it_be(:controller_actions) do - # This will return tuples of all controller actions defined in the routes - # Only for controllers inheriting ApplicationController - # Excluding controllers from gems (OAuth, Sidekiq) - Rails.application.routes.routes - .map { |route| route.required_defaults.presence } - .compact - .select { |route| route[:controller].present? && route[:action].present? } - .map { |route| [constantize_controller(route[:controller]), route[:action]] } - .select { |(controller, action)| controller&.include?(::Gitlab::WithFeatureCategory) } - .reject { |(controller, action)| controller == ApplicationController || controller == Devise::UnlocksController } + Gitlab::RequestEndpoints.all_controller_actions end let_it_be(:routes_without_category) do @@ -74,9 +64,6 @@ RSpec.describe "Every controller" do end def actions_defined_in_feature_category_config(controller) - controller.send(:class_attributes)[:feature_category_config] - .values - .flatten - .map(&:to_s) + controller.send(:class_attributes)[:endpoint_attributes_config].defined_actions end end diff --git a/spec/controllers/graphql_controller_spec.rb b/spec/controllers/graphql_controller_spec.rb index aed97a01a72..6e7bcfdaa08 100644 --- a/spec/controllers/graphql_controller_spec.rb +++ b/spec/controllers/graphql_controller_spec.rb @@ -38,6 +38,14 @@ RSpec.describe GraphqlController do sign_in(user) end + it 'sets feature category in ApplicationContext from request' do + request.headers["HTTP_X_GITLAB_FEATURE_CATEGORY"] = "web_ide" + + post :execute + + expect(::Gitlab::ApplicationContext.current_context_attribute(:feature_category)).to eq('web_ide') + end + it 'returns 200 when user can access API' do post :execute 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 7415c2860c8..fa402d556c7 100644 --- a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb +++ b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe Groups::DependencyProxyForContainersController do include HttpBasicAuthHelpers include DependencyProxyHelpers + include WorkhorseHelpers let_it_be(:user) { create(:user) } let_it_be_with_reload(:group) { create(:group, :private) } @@ -242,16 +243,9 @@ RSpec.describe Groups::DependencyProxyForContainersController do end describe 'GET #blob' do - let_it_be(:blob) { create(:dependency_proxy_blob) } + let(:blob) { create(:dependency_proxy_blob, group: group) } let(:blob_sha) { blob.file_name.sub('.gz', '') } - let(:blob_response) { { status: :success, blob: blob, from_cache: false } } - - before do - allow_next_instance_of(DependencyProxy::FindOrCreateBlobService) do |instance| - allow(instance).to receive(:execute).and_return(blob_response) - end - end subject { get_blob } @@ -264,40 +258,31 @@ RSpec.describe Groups::DependencyProxyForContainersController do it_behaves_like 'without permission' it_behaves_like 'feature flag disabled with private group' - context 'remote blob request fails' do - let(:blob_response) do - { - status: :error, - http_status: 400, - message: '' - } - end - - before do - group.add_guest(user) - end - - it 'proxies status from the remote blob request', :aggregate_failures do - subject - - expect(response).to have_gitlab_http_status(:bad_request) - expect(response.body).to be_empty - end - end - context 'a valid user' do before do group.add_guest(user) end it_behaves_like 'a successful blob pull' - it_behaves_like 'a package tracking event', described_class.name, 'pull_blob' + it_behaves_like 'a package tracking event', described_class.name, 'pull_blob_from_cache' - context 'with a cache entry' do - let(:blob_response) { { status: :success, blob: blob, from_cache: true } } + context 'when cache entry does not exist' do + let(:blob_sha) { 'a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4' } - it_behaves_like 'returning response status', :success - it_behaves_like 'a package tracking event', described_class.name, 'pull_blob_from_cache' + it 'returns Workhorse send-dependency instructions' do + subject + + send_data_type, send_data = workhorse_send_data + header, url = send_data.values_at('Header', 'Url') + + expect(send_data_type).to eq('send-dependency') + expect(header).to eq("Authorization" => ["Bearer abcd1234"]) + expect(url).to eq(DependencyProxy::Registry.blob_url('alpine', blob_sha)) + expect(response.headers['Content-Type']).to eq('application/gzip') + expect(response.headers['Content-Disposition']).to eq( + ActionDispatch::Http::ContentDisposition.format(disposition: 'attachment', filename: blob.file_name) + ) + end end end @@ -319,6 +304,74 @@ RSpec.describe Groups::DependencyProxyForContainersController do it_behaves_like 'a successful blob pull' end end + + context 'when dependency_proxy_workhorse disabled' do + let(:blob_response) { { status: :success, blob: blob, from_cache: false } } + + before do + stub_feature_flags(dependency_proxy_workhorse: false) + + allow_next_instance_of(DependencyProxy::FindOrCreateBlobService) do |instance| + allow(instance).to receive(:execute).and_return(blob_response) + end + end + + context 'remote blob request fails' do + let(:blob_response) do + { + status: :error, + http_status: 400, + message: '' + } + end + + before do + group.add_guest(user) + end + + it 'proxies status from the remote blob request', :aggregate_failures do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + expect(response.body).to be_empty + end + end + + context 'a valid user' do + before do + group.add_guest(user) + end + + it_behaves_like 'a successful blob pull' + it_behaves_like 'a package tracking event', described_class.name, 'pull_blob' + + context 'with a cache entry' do + let(:blob_response) { { status: :success, blob: blob, from_cache: true } } + + it_behaves_like 'returning response status', :success + it_behaves_like 'a package tracking event', described_class.name, 'pull_blob_from_cache' + end + end + + context 'a valid deploy token' do + let_it_be(:user) { create(:deploy_token, :group, :dependency_proxy_scopes) } + let_it_be(:group_deploy_token) { create(:group_deploy_token, deploy_token: user, group: group) } + + it_behaves_like 'a successful blob pull' + + context 'pulling from a subgroup' do + let_it_be_with_reload(:parent_group) { create(:group) } + let_it_be_with_reload(:group) { create(:group, parent: parent_group) } + + before do + parent_group.create_dependency_proxy_setting!(enabled: true) + group_deploy_token.update_column(:group_id, parent_group.id) + end + + it_behaves_like 'a successful blob pull' + end + end + end end it_behaves_like 'not found when disabled' @@ -328,6 +381,61 @@ RSpec.describe Groups::DependencyProxyForContainersController do end end + describe 'GET #authorize_upload_blob' do + let(:blob_sha) { 'a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4' } + + subject(:authorize_upload_blob) do + request.headers.merge!(workhorse_internal_api_request_header) + + get :authorize_upload_blob, params: { group_id: group.to_param, image: 'alpine', sha: blob_sha } + end + + it_behaves_like 'without permission' + + context 'with a valid user' do + before do + group.add_guest(user) + end + + it 'sends Workhorse file upload instructions', :aggregate_failures do + authorize_upload_blob + + expect(response.headers['Content-Type']).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(json_response['TempPath']).to eq(DependencyProxy::FileUploader.workhorse_local_upload_path) + end + end + end + + describe 'GET #upload_blob' do + let(:blob_sha) { 'a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4' } + let(:file) { fixture_file_upload("spec/fixtures/dependency_proxy/#{blob_sha}.gz", 'application/gzip') } + + subject do + request.headers.merge!(workhorse_internal_api_request_header) + + get :upload_blob, params: { + group_id: group.to_param, + image: 'alpine', + sha: blob_sha, + file: file + } + end + + it_behaves_like 'without permission' + + context 'with a valid user' do + before do + group.add_guest(user) + + expect_next_found_instance_of(Group) do |instance| + expect(instance).to receive_message_chain(:dependency_proxy_blobs, :create!) + end + end + + it_behaves_like 'a package tracking event', described_class.name, 'pull_blob' + end + end + def enable_dependency_proxy group.create_dependency_proxy_setting!(enabled: true) end diff --git a/spec/controllers/groups/registry/repositories_controller_spec.rb b/spec/controllers/groups/registry/repositories_controller_spec.rb index f4541eda293..9ac19b06718 100644 --- a/spec/controllers/groups/registry/repositories_controller_spec.rb +++ b/spec/controllers/groups/registry/repositories_controller_spec.rb @@ -19,6 +19,7 @@ RSpec.describe Groups::Registry::RepositoriesController do before do stub_container_registry_config(enabled: true) stub_container_registry_tags(repository: :any, tags: []) + stub_container_registry_info group.add_owner(user) group.add_guest(guest) sign_in(user) @@ -37,6 +38,18 @@ RSpec.describe Groups::Registry::RepositoriesController do 'name' => repo.name ) end + + [ContainerRegistry::Path::InvalidRegistryPathError, Faraday::Error].each do |error_class| + context "when there is a #{error_class}" do + it 'displays a connection error message' do + expect(::ContainerRegistry::Client).to receive(:registry_info).and_raise(error_class, nil, nil) + + subject + + expect(response).to have_gitlab_http_status(:ok) + end + end + end end shared_examples 'with name parameter' do @@ -71,6 +84,18 @@ RSpec.describe Groups::Registry::RepositoriesController do expect(response).to have_gitlab_http_status(:ok) expect_no_snowplow_event end + + [ContainerRegistry::Path::InvalidRegistryPathError, Faraday::Error].each do |error_class| + context "when there is an invalid path error #{error_class}" do + it 'displays a connection error message' do + expect(::ContainerRegistry::Client).to receive(:registry_info).and_raise(error_class, nil, nil) + + subject + + expect(response).to have_gitlab_http_status(:ok) + end + end + end end context 'json format' do diff --git a/spec/controllers/help_controller_spec.rb b/spec/controllers/help_controller_spec.rb index 599e82afe9b..4e2123c8cc4 100644 --- a/spec/controllers/help_controller_spec.rb +++ b/spec/controllers/help_controller_spec.rb @@ -34,14 +34,6 @@ RSpec.describe HelpController do is_expected.to redirect_to("#{documentation_base_url}/ee/#{path}.html") end end - - context 'when feature flag is disabled' do - before do - stub_feature_flags(help_page_documentation_redirect: false) - end - - it_behaves_like 'documentation pages local render' - end end before do diff --git a/spec/controllers/import/bulk_imports_controller_spec.rb b/spec/controllers/import/bulk_imports_controller_spec.rb index 3b2ed2c63ed..3adba32c74a 100644 --- a/spec/controllers/import/bulk_imports_controller_spec.rb +++ b/spec/controllers/import/bulk_imports_controller_spec.rb @@ -51,62 +51,87 @@ RSpec.describe Import::BulkImportsController do end describe 'GET status' do + def get_status(params_override = {}) + params = { page: 1, per_page: 20, filter: '' }.merge(params_override) + + get :status, + params: params, + format: :json, + session: { + bulk_import_gitlab_url: 'https://gitlab.example.com', + bulk_import_gitlab_access_token: 'demo-pat' + } + end + + include_context 'bulk imports requests context', 'https://gitlab.example.com' + let(:client) { BulkImports::Clients::HTTP.new(url: 'http://gitlab.example', token: 'token') } + let(:version) { "#{BulkImport::MIN_MAJOR_VERSION}.#{BulkImport::MIN_MINOR_VERSION_FOR_PROJECT}.0" } + let(:version_response) { double(code: 200, success?: true, parsed_response: { 'version' => version }) } describe 'serialized group data' do - let(:client_response) do + let(:expected_response) do double( parsed_response: [ - { 'id' => 1, 'full_name' => 'group1', 'full_path' => 'full/path/group1', 'web_url' => 'http://demo.host/full/path/group1' }, - { 'id' => 2, 'full_name' => 'group2', 'full_path' => 'full/path/group2', 'web_url' => 'http://demo.host/full/path/group1' } + { + "full_name" => "Stub", + "full_path" => "stub-group", + "id" => 2595438, + "web_url" => "https://gitlab.com/groups/auto-breakfast" + } ], headers: { 'x-next-page' => '2', 'x-page' => '1', 'x-per-page' => '20', - 'x-total' => '37', + 'x-total' => '42', 'x-total-pages' => '2' } ) end - let(:client_params) do - { - top_level_only: true, - min_access_level: Gitlab::Access::OWNER - } - end - - before do - allow(controller).to receive(:client).and_return(client) - allow(client).to receive(:get).with('groups', client_params).and_return(client_response) - end - it 'returns serialized group data' do - get :status, format: :json + get_status + + version_validation = { + "features" => { + "project_migration" => { + "available" => true, + "min_version" => BulkImport.min_gl_version_for_project_migration.to_s + }, + "source_instance_version" => version + } + } - expect(json_response).to eq({ importable_data: client_response.parsed_response }.as_json) + expect(json_response).to include("importable_data" => expected_response.parsed_response, "version_validation" => hash_including(version_validation)) end it 'forwards pagination headers' do - get :status, format: :json - - expect(response.headers['x-per-page']).to eq client_response.headers['x-per-page'] - expect(response.headers['x-page']).to eq client_response.headers['x-page'] - expect(response.headers['x-next-page']).to eq client_response.headers['x-next-page'] - expect(response.headers['x-prev-page']).to eq client_response.headers['x-prev-page'] - expect(response.headers['x-total']).to eq client_response.headers['x-total'] - expect(response.headers['x-total-pages']).to eq client_response.headers['x-total-pages'] + get_status + + expect(response.headers['x-per-page']).to eq expected_response.headers['x-per-page'] + expect(response.headers['x-page']).to eq expected_response.headers['x-page'] + expect(response.headers['x-next-page']).to eq expected_response.headers['x-next-page'] + expect(response.headers['x-prev-page']).to eq expected_response.headers['x-prev-page'] + expect(response.headers['x-total']).to eq expected_response.headers['x-total'] + expect(response.headers['x-total-pages']).to eq expected_response.headers['x-total-pages'] end context 'when filtering' do - it 'returns filtered result' do - filter = 'test' - search_params = client_params.merge(search: filter) + let_it_be(:filter) { 'test' } - expect(client).to receive(:get).with('groups', search_params).and_return(client_response) + let(:client_params) do + { + top_level_only: true, + min_access_level: Gitlab::Access::OWNER, + search: filter + } + end + + it 'returns filtered result' do + get_status(filter: filter) - get :status, format: :json, params: { filter: filter } + expect(json_response['importable_data'].first['full_name']).to eq('Test') end end end @@ -148,18 +173,19 @@ RSpec.describe Import::BulkImportsController do context 'when connection error occurs' do before do - allow(controller).to receive(:client).and_return(client) - allow(client).to receive(:get).and_raise(BulkImports::Error) + allow_next_instance_of(BulkImports::Clients::HTTP) do |instance| + allow(instance).to receive(:get).and_raise(BulkImports::Error) + end end it 'returns 422' do - get :status, format: :json + get_status expect(response).to have_gitlab_http_status(:unprocessable_entity) end it 'clears session' do - get :status, format: :json + get_status expect(session[:gitlab_url]).to be_nil expect(session[:gitlab_access_token]).to be_nil @@ -199,9 +225,9 @@ RSpec.describe Import::BulkImportsController do session[:bulk_import_gitlab_url] = instance_url end - it 'executes BulkImportService' do + it 'executes BulkImpors::CreatetService' do expect_next_instance_of( - BulkImportService, user, bulk_import_params, { url: instance_url, access_token: pat }) do |service| + ::BulkImports::CreateService, user, bulk_import_params, { url: instance_url, access_token: pat }) do |service| allow(service).to receive(:execute).and_return(ServiceResponse.success(payload: bulk_import)) end @@ -214,7 +240,7 @@ RSpec.describe Import::BulkImportsController do 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| + ::BulkImports::CreateService, user, bulk_import_params, { url: instance_url, access_token: pat }) do |service| allow(service).to receive(:execute).and_return(error_response) end diff --git a/spec/controllers/jira_connect/app_descriptor_controller_spec.rb b/spec/controllers/jira_connect/app_descriptor_controller_spec.rb index 25c11d92b4e..9d890efdd33 100644 --- a/spec/controllers/jira_connect/app_descriptor_controller_spec.rb +++ b/spec/controllers/jira_connect/app_descriptor_controller_spec.rb @@ -46,7 +46,8 @@ RSpec.describe JiraConnect::AppDescriptorController do apiVersion: 1, apiMigrations: { 'context-qsh': true, - gdpr: true + gdpr: true, + 'signed-install': true } ) @@ -89,5 +90,17 @@ RSpec.describe JiraConnect::AppDescriptorController do ) ) end + + context 'when jira_connect_asymmetric_jwt is disabled' do + before do + stub_feature_flags(jira_connect_asymmetric_jwt: false) + end + + specify do + get :show + + expect(json_response).to include('apiMigrations' => include('signed-install' => false)) + end + end end end diff --git a/spec/controllers/jira_connect/events_controller_spec.rb b/spec/controllers/jira_connect/events_controller_spec.rb index e9fecb594a7..78bd0dc8318 100644 --- a/spec/controllers/jira_connect/events_controller_spec.rb +++ b/spec/controllers/jira_connect/events_controller_spec.rb @@ -3,9 +3,49 @@ require 'spec_helper' RSpec.describe JiraConnect::EventsController do + shared_examples 'verifies asymmetric JWT token' do + context 'when token is valid' do + include_context 'valid JWT token' + + it 'renders successful' do + send_request + + expect(response).to have_gitlab_http_status(:success) + end + end + + context 'when token is invalid' do + include_context 'invalid JWT token' + + it 'renders unauthorized' do + send_request + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end + + shared_context 'valid JWT token' do + before do + allow_next_instance_of(Atlassian::JiraConnect::AsymmetricJwt) do |asymmetric_jwt| + allow(asymmetric_jwt).to receive(:valid?).and_return(true) + allow(asymmetric_jwt).to receive(:iss_claim).and_return(client_key) + end + end + end + + shared_context 'invalid JWT token' do + before do + allow_next_instance_of(Atlassian::JiraConnect::AsymmetricJwt) do |asymmetric_jwt| + allow(asymmetric_jwt).to receive(:valid?).and_return(false) + end + end + end + describe '#installed' do let(:client_key) { '1234' } let(:shared_secret) { 'secret' } + let(:params) do { clientKey: client_key, @@ -14,10 +54,16 @@ RSpec.describe JiraConnect::EventsController do } end + include_context 'valid JWT token' + subject do post :installed, params: params end + it_behaves_like 'verifies asymmetric JWT token' do + let(:send_request) { subject } + end + it 'saves the jira installation data' do expect { subject }.to change { JiraConnectInstallation.count }.by(1) end @@ -31,13 +77,15 @@ RSpec.describe JiraConnect::EventsController do expect(installation.base_url).to eq('https://test.atlassian.net') end - context 'client key already exists' do - it 'returns 422' do - create(:jira_connect_installation, client_key: client_key) + context 'when jira_connect_asymmetric_jwt is disabled' do + before do + stub_feature_flags(jira_connect_asymmetric_jwt: false) + end - subject + it 'saves the jira installation data without JWT validation' do + expect(Atlassian::JiraConnect::AsymmetricJwt).not_to receive(:new) - expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect { subject }.to change { JiraConnectInstallation.count }.by(1) end end @@ -49,27 +97,68 @@ RSpec.describe JiraConnect::EventsController do } end - it 'validates the JWT token in authorization header and returns 200 without creating a new installation' do - create(:jira_connect_installation, client_key: client_key, shared_secret: shared_secret) - request.headers["Authorization"] = "Bearer #{Atlassian::Jwt.encode({ iss: client_key }, shared_secret)}" + it 'returns 422' do + subject - expect { subject }.not_to change { JiraConnectInstallation.count } - expect(response).to have_gitlab_http_status(:ok) + expect(response).to have_gitlab_http_status(:unprocessable_entity) end - end - describe '#uninstalled' do - let!(:installation) { create(:jira_connect_installation) } - let(:qsh) { Atlassian::Jwt.create_query_string_hash('https://gitlab.test/events/uninstalled', 'POST', 'https://gitlab.test') } + context 'and an installation exists' do + let!(:installation) { create(:jira_connect_installation, client_key: client_key, shared_secret: shared_secret) } - before do - request.headers['Authorization'] = "JWT #{auth_token}" + it 'validates the JWT token in authorization header and returns 200 without creating a new installation' do + expect { subject }.not_to change { JiraConnectInstallation.count } + expect(response).to have_gitlab_http_status(:ok) + end + + context 'when jira_connect_asymmetric_jwt is disabled' do + before do + stub_feature_flags(jira_connect_asymmetric_jwt: false) + end + + it 'decodes the JWT token in authorization header and returns 200 without creating a new installation' do + request.headers["Authorization"] = "Bearer #{Atlassian::Jwt.encode({ iss: client_key }, shared_secret)}" + + expect(Atlassian::JiraConnect::AsymmetricJwt).not_to receive(:new) + + expect { subject }.not_to change { JiraConnectInstallation.count } + + expect(response).to have_gitlab_http_status(:ok) + end + end end + end + end + + describe '#uninstalled' do + let_it_be(:installation) { create(:jira_connect_installation) } + + let(:client_key) { installation.client_key } + let(:params) do + { + clientKey: client_key, + baseUrl: 'https://test.atlassian.net' + } + end + + it_behaves_like 'verifies asymmetric JWT token' do + let(:send_request) { post :uninstalled, params: params } + end + + subject(:post_uninstalled) { post :uninstalled, params: params } - subject(:post_uninstalled) { post :uninstalled } + context 'when JWT is invalid' do + include_context 'invalid JWT token' - context 'when JWT is invalid' do - let(:auth_token) { 'invalid_token' } + it 'does not delete the installation' do + expect { post_uninstalled }.not_to change { JiraConnectInstallation.count } + end + + context 'when jira_connect_asymmetric_jwt is disabled' do + before do + stub_feature_flags(jira_connect_asymmetric_jwt: false) + request.headers['Authorization'] = 'JWT invalid token' + end it 'returns 403' do post_uninstalled @@ -81,14 +170,42 @@ RSpec.describe JiraConnect::EventsController do expect { post_uninstalled }.not_to change { JiraConnectInstallation.count } end end + end + + context 'when JWT is valid' do + include_context 'valid JWT token' + + let(:jira_base_path) { '/-/jira_connect' } + let(:jira_event_path) { '/-/jira_connect/events/uninstalled' } + + it 'calls the DestroyService and returns ok in case of success' do + expect_next_instance_of(JiraConnectInstallations::DestroyService, installation, jira_base_path, jira_event_path) do |destroy_service| + expect(destroy_service).to receive(:execute).and_return(true) + end + + post_uninstalled + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'calls the DestroyService and returns unprocessable_entity in case of failure' do + expect_next_instance_of(JiraConnectInstallations::DestroyService, installation, jira_base_path, jira_event_path) do |destroy_service| + expect(destroy_service).to receive(:execute).and_return(false) + end + + post_uninstalled + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + end + + context 'when jira_connect_asymmetric_jwt is disabled' do + before do + stub_feature_flags(jira_connect_asymmetric_jwt: false) - context 'when JWT is valid' do - let(:auth_token) do - Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret) + request.headers['Authorization'] = "JWT #{Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret)}" end - let(:jira_base_path) { '/-/jira_connect' } - let(:jira_event_path) { '/-/jira_connect/events/uninstalled' } + let(:qsh) { Atlassian::Jwt.create_query_string_hash('https://gitlab.test/events/uninstalled', 'POST', 'https://gitlab.test') } it 'calls the DestroyService and returns ok in case of success' do expect_next_instance_of(JiraConnectInstallations::DestroyService, installation, jira_base_path, jira_event_path) do |destroy_service| diff --git a/spec/controllers/metrics_controller_spec.rb b/spec/controllers/metrics_controller_spec.rb index 9fa90dde997..4f74af295c6 100644 --- a/spec/controllers/metrics_controller_spec.rb +++ b/spec/controllers/metrics_controller_spec.rb @@ -67,6 +67,12 @@ RSpec.describe MetricsController, :request_store do expect(response.body).to match(/^prometheus_counter 1$/) end + it 'initializes the rails request SLIs' do + expect(Gitlab::Metrics::RailsSlis).to receive(:initialize_request_slis_if_needed!).and_call_original + + get :index + end + context 'prometheus metrics are disabled' do before do allow(Gitlab::Metrics).to receive(:prometheus_metrics_enabled?).and_return(false) diff --git a/spec/controllers/profiles/two_factor_auths_controller_spec.rb b/spec/controllers/profiles/two_factor_auths_controller_spec.rb index ca63760d988..e57bd5be937 100644 --- a/spec/controllers/profiles/two_factor_auths_controller_spec.rb +++ b/spec/controllers/profiles/two_factor_auths_controller_spec.rb @@ -27,12 +27,6 @@ RSpec.describe Profiles::TwoFactorAuthsController do expect(flash[:notice]) .to eq _('You need to verify your primary email first before enabling Two-Factor Authentication.') end - - it 'does not redirect when the `ensure_verified_primary_email_for_2fa` feature flag is disabled' do - stub_feature_flags(ensure_verified_primary_email_for_2fa: false) - - expect(response).not_to redirect_to(profile_emails_path) - end end shared_examples 'user must enter a valid current password' do diff --git a/spec/controllers/profiles_controller_spec.rb b/spec/controllers/profiles_controller_spec.rb index b4019643baf..4959003d788 100644 --- a/spec/controllers/profiles_controller_spec.rb +++ b/spec/controllers/profiles_controller_spec.rb @@ -3,7 +3,8 @@ require('spec_helper') RSpec.describe ProfilesController, :request_store do - let(:user) { create(:user) } + let(:password) { 'longsecret987!' } + let(:user) { create(:user, password: password) } describe 'POST update' do it 'does not update password' do @@ -23,7 +24,7 @@ RSpec.describe ProfilesController, :request_store do sign_in(user) put :update, - params: { user: { email: "john@gmail.com", name: "John" } } + params: { user: { email: "john@gmail.com", name: "John", validation_password: password } } user.reload diff --git a/spec/controllers/projects/alerting/notifications_controller_spec.rb b/spec/controllers/projects/alerting/notifications_controller_spec.rb index fe0c4ce00bf..2fff8026b22 100644 --- a/spec/controllers/projects/alerting/notifications_controller_spec.rb +++ b/spec/controllers/projects/alerting/notifications_controller_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe Projects::Alerting::NotificationsController do + include HttpBasicAuthHelpers + let_it_be(:project) { create(:project) } let_it_be(:environment) { create(:environment, project: project) } @@ -53,86 +55,96 @@ RSpec.describe Projects::Alerting::NotificationsController do end end - context 'bearer token' do - context 'when set' do - context 'when extractable' do - before do - request.headers['HTTP_AUTHORIZATION'] = 'Bearer some token' - end - - it 'extracts bearer token' do - expect(notify_service).to receive(:execute).with('some token', nil) - - make_request - end - - context 'with a corresponding integration' do - context 'with integration parameters specified' do - let_it_be_with_reload(:integration) { create(:alert_management_http_integration, project: project) } + shared_examples 'a working token' do + it 'extracts token' do + expect(notify_service).to receive(:execute).with('some token', nil) - let(:params) { project_params(endpoint_identifier: integration.endpoint_identifier, name: integration.name) } - - context 'the integration is active' do - it 'extracts and finds the integration' do - expect(notify_service).to receive(:execute).with('some token', integration) + make_request + end - make_request - end - end + context 'with a corresponding integration' do + context 'with integration parameters specified' do + let_it_be_with_reload(:integration) { create(:alert_management_http_integration, project: project) } - context 'when the integration is inactive' do - before do - integration.update!(active: false) - end + let(:params) { project_params(endpoint_identifier: integration.endpoint_identifier, name: integration.name) } - it 'does not find an integration' do - expect(notify_service).to receive(:execute).with('some token', nil) + context 'the integration is active' do + it 'extracts and finds the integration' do + expect(notify_service).to receive(:execute).with('some token', integration) - make_request - end - end + make_request end + end - context 'without integration parameters specified' do - let_it_be(:integration) { create(:alert_management_http_integration, :legacy, project: project) } + context 'when the integration is inactive' do + before do + integration.update!(active: false) + end - it 'extracts and finds the legacy integration' do - expect(notify_service).to receive(:execute).with('some token', integration) + it 'does not find an integration' do + expect(notify_service).to receive(:execute).with('some token', nil) - make_request - end + make_request end end end - context 'when inextractable' do - it 'passes nil for a non-bearer token' do - request.headers['HTTP_AUTHORIZATION'] = 'some token' + context 'without integration parameters specified' do + let_it_be(:integration) { create(:alert_management_http_integration, :legacy, project: project) } - expect(notify_service).to receive(:execute).with(nil, nil) + it 'extracts and finds the legacy integration' do + expect(notify_service).to receive(:execute).with('some token', integration) make_request end end end + end - context 'when missing' do - it 'passes nil' do - expect(notify_service).to receive(:execute).with(nil, nil) - - make_request + context 'with bearer token' do + context 'when set' do + before do + request.headers.merge(build_token_auth_header('some token')) end + + it_behaves_like 'a working token' + end + end + + context 'with basic auth token' do + before do + request.headers.merge basic_auth_header(nil, 'some token') + end + + it_behaves_like 'a working token' + end + + context 'when inextractable token' do + it 'passes nil for a non-bearer token' do + request.headers['HTTP_AUTHORIZATION'] = 'some token' + + expect(notify_service).to receive(:execute).with(nil, nil) + + make_request + end + end + + context 'when missing token' do + it 'passes nil' do + expect(notify_service).to receive(:execute).with(nil, nil) + + make_request end end end - context 'generic alert payload' do + context 'with generic alert payload' do it_behaves_like 'process alert payload', Projects::Alerting::NotifyService do let(:payload) { { title: 'Alert title' } } end end - context 'Prometheus alert payload' do + context 'with Prometheus alert payload' do include PrometheusHelpers it_behaves_like 'process alert payload', Projects::Prometheus::Alerts::NotifyService do diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb index a00e302a64f..43e8bbd83cf 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -239,7 +239,7 @@ RSpec.describe Projects::BranchesController do end end - context 'without issue feature access' do + context 'without issue feature access', :sidekiq_inline do before do project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC) project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE) @@ -656,6 +656,26 @@ RSpec.describe Projects::BranchesController do ) end end + + context 'when gitaly is not available' do + before do + allow_next_instance_of(Gitlab::GitalyClient::RefService) do |ref_service| + allow(ref_service).to receive(:local_branches).and_raise(GRPC::DeadlineExceeded) + end + + get :index, format: :html, params: { + namespace_id: project.namespace, project_id: project + } + end + + it 'returns with a status 200' do + expect(response).to have_gitlab_http_status(:ok) + end + + it 'sets gitaly_unavailable variable' do + expect(assigns[:gitaly_unavailable]).to be_truthy + end + end end describe 'GET diverging_commit_counts' do diff --git a/spec/controllers/projects/compare_controller_spec.rb b/spec/controllers/projects/compare_controller_spec.rb index 2412b970342..48afd42e8ff 100644 --- a/spec/controllers/projects/compare_controller_spec.rb +++ b/spec/controllers/projects/compare_controller_spec.rb @@ -409,7 +409,7 @@ RSpec.describe Projects::CompareController do end end - context 'when the user does not have access to the project' do + context 'when the user does not have access to the project', :sidekiq_inline do before do project.team.truncate project.update!(visibility: 'private') diff --git a/spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb b/spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb index 56c0ef592ca..cc0f4a426f4 100644 --- a/spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb +++ b/spec/controllers/projects/design_management/designs/resized_image_controller_spec.rb @@ -91,7 +91,7 @@ RSpec.describe Projects::DesignManagement::Designs::ResizedImageController do # (the record that represents the design at a specific version), to # verify that the correct file is being returned. def etag(action) - ActionDispatch::TestResponse.new.send(:generate_weak_etag, [action.cache_key, '']) + ActionDispatch::TestResponse.new.send(:generate_weak_etag, [action.cache_key]) end specify { expect(newest_version.sha).not_to eq(oldest_version.sha) } diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 977879b453c..0b3bd4d78ac 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -1411,39 +1411,42 @@ RSpec.describe Projects::IssuesController do stub_application_setting(issues_create_limit: 5) end - it 'prevents from creating more issues', :request_store do - 5.times { post_new_issue } - - expect { post_new_issue } - .to change { Gitlab::GitalyClient.get_request_count }.by(1) # creates 1 projects and 0 issues - - post_new_issue - 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 on auth.log' do - attributes = { - message: 'Application_Rate_Limiter_Request', - env: :issues_create_request_limit, - remote_ip: '0.0.0.0', - request_method: 'POST', - path: "/#{project.full_path}/-/issues", - user_id: user.id, - username: user.username - } + context 'when issue creation limits imposed' do + it 'prevents from creating more issues', :request_store do + 5.times { post_new_issue } - expect(Gitlab::AuthLogger).to receive(:error).with(attributes).once + expect { post_new_issue } + .to change { Gitlab::GitalyClient.get_request_count }.by(1) # creates 1 projects and 0 issues - project.add_developer(user) - sign_in(user) + post_new_issue - 6.times do - post :create, params: { - namespace_id: project.namespace.to_param, - project_id: project, - issue: { title: 'Title', description: 'Description' } + 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 on auth.log' do + attributes = { + message: 'Application_Rate_Limiter_Request', + env: :issues_create_request_limit, + remote_ip: '0.0.0.0', + request_method: 'POST', + path: "/#{project.full_path}/-/issues", + user_id: user.id, + username: user.username } + + expect(Gitlab::AuthLogger).to receive(:error).with(attributes).once + + project.add_developer(user) + sign_in(user) + + 6.times do + post :create, params: { + namespace_id: project.namespace.to_param, + project_id: project, + issue: { title: 'Title', description: 'Description' } + } + end end end end diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 0da8a30611c..438fc2f2106 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -1876,8 +1876,7 @@ RSpec.describe Projects::MergeRequestsController do let(:sha) { forked.commit.sha } let(:environment) { create(:environment, project: forked) } let(:pipeline) { create(:ci_pipeline, sha: sha, project: forked) } - let(:build) { create(:ci_build, pipeline: pipeline) } - let!(:deployment) { create(:deployment, :succeed, environment: environment, sha: sha, ref: 'master', deployable: build) } + let!(:build) { create(:ci_build, :with_deployment, environment: environment.name, pipeline: pipeline) } let(:merge_request) do create(:merge_request, source_project: forked, target_project: project, target_branch: 'master', head_pipeline: pipeline) @@ -1901,8 +1900,7 @@ RSpec.describe Projects::MergeRequestsController do let(:source_environment) { create(:environment, project: project) } let(:merge_commit_sha) { project.repository.merge(user, forked.commit.id, merge_request, "merged in test") } let(:post_merge_pipeline) { create(:ci_pipeline, sha: merge_commit_sha, project: project) } - let(:post_merge_build) { create(:ci_build, pipeline: post_merge_pipeline) } - let!(:source_deployment) { create(:deployment, :succeed, environment: source_environment, sha: merge_commit_sha, ref: 'master', deployable: post_merge_build) } + let!(:post_merge_build) { create(:ci_build, :with_deployment, environment: source_environment.name, pipeline: post_merge_pipeline) } before do merge_request.update!(merge_commit_sha: merge_commit_sha) @@ -1944,9 +1942,6 @@ RSpec.describe Projects::MergeRequestsController do context 'when a merge request has multiple environments with deployments' do let(:sha) { merge_request.diff_head_sha } - let(:ref) { merge_request.source_branch } - - let!(:build) { create(:ci_build, pipeline: pipeline) } let!(:pipeline) { create(:ci_pipeline, sha: sha, project: project) } let!(:environment) { create(:environment, name: 'env_a', project: project) } let!(:another_environment) { create(:environment, name: 'env_b', project: project) } @@ -1954,8 +1949,8 @@ RSpec.describe Projects::MergeRequestsController do before do merge_request.update_head_pipeline - create(:deployment, :succeed, environment: environment, sha: sha, ref: ref, deployable: build) - create(:deployment, :succeed, environment: another_environment, sha: sha, ref: ref, deployable: build) + create(:ci_build, :with_deployment, environment: environment.name, pipeline: pipeline) + create(:ci_build, :with_deployment, environment: another_environment.name, pipeline: pipeline) end it 'exposes multiple environment statuses' do diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb index 27a3e95896a..d86f38c1f0b 100644 --- a/spec/controllers/projects/pipeline_schedules_controller_spec.rb +++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb @@ -397,7 +397,7 @@ RSpec.describe Projects::PipelineSchedulesController do end end - describe 'POST #play', :clean_gitlab_redis_cache do + describe 'POST #play', :clean_gitlab_redis_rate_limiting do let(:ref) { 'master' } before do diff --git a/spec/controllers/projects/raw_controller_spec.rb b/spec/controllers/projects/raw_controller_spec.rb index 2c25c7e20ea..a81173ccaac 100644 --- a/spec/controllers/projects/raw_controller_spec.rb +++ b/spec/controllers/projects/raw_controller_spec.rb @@ -84,7 +84,7 @@ RSpec.describe Projects::RawController do include_examples 'single Gitaly request' end - context 'when the endpoint receives requests above the limit', :clean_gitlab_redis_cache do + context 'when the endpoint receives requests above the limit', :clean_gitlab_redis_rate_limiting do let(:file_path) { 'master/README.md' } before do diff --git a/spec/controllers/projects/registry/repositories_controller_spec.rb b/spec/controllers/projects/registry/repositories_controller_spec.rb index 0685e5a2055..a5faaaf5969 100644 --- a/spec/controllers/projects/registry/repositories_controller_spec.rb +++ b/spec/controllers/projects/registry/repositories_controller_spec.rb @@ -9,6 +9,7 @@ RSpec.describe Projects::Registry::RepositoriesController do before do sign_in(user) stub_container_registry_config(enabled: true) + stub_container_registry_info end context 'when user has access to registry' do @@ -30,6 +31,18 @@ RSpec.describe Projects::Registry::RepositoriesController do expect(response).to have_gitlab_http_status(:not_found) end + + [ContainerRegistry::Path::InvalidRegistryPathError, Faraday::Error].each do |error_class| + context "when there is a #{error_class}" do + it 'displays a connection error message' do + expect(::ContainerRegistry::Client).to receive(:registry_info).and_raise(error_class, nil, nil) + + go_to_index + + expect(response).to have_gitlab_http_status(:ok) + end + end + end end shared_examples 'renders a list of repositories' do diff --git a/spec/controllers/projects/tags_controller_spec.rb b/spec/controllers/projects/tags_controller_spec.rb index efb57494f82..d0719643b7f 100644 --- a/spec/controllers/projects/tags_controller_spec.rb +++ b/spec/controllers/projects/tags_controller_spec.rb @@ -17,6 +17,25 @@ RSpec.describe Projects::TagsController do expect(assigns(:tags).map(&:name)).to include('v1.1.0', 'v1.0.0') end + context 'when Gitaly is unavailable' do + where(:format) do + [:html, :atom] + end + + with_them do + it 'returns 503 status code' do + expect_next_instance_of(TagsFinder) do |finder| + expect(finder).to receive(:execute).and_return([[], Gitlab::Git::CommandError.new]) + end + + get :index, params: { namespace_id: project.namespace.to_param, project_id: project }, format: format + + expect(assigns(:tags)).to eq([]) + expect(response).to have_gitlab_http_status(:service_unavailable) + end + end + end + it 'returns releases matching those tags' do subject diff --git a/spec/controllers/projects/usage_quotas_controller_spec.rb b/spec/controllers/projects/usage_quotas_controller_spec.rb new file mode 100644 index 00000000000..6125ba13f96 --- /dev/null +++ b/spec/controllers/projects/usage_quotas_controller_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::UsageQuotasController do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, namespace: user.namespace) } + + describe 'GET #index' do + render_views + + it 'does not render search settings partial' do + sign_in(user) + get(:index, params: { namespace_id: user.namespace, project_id: project }) + + expect(response).to render_template('index') + expect(response).not_to render_template('shared/search_settings') + end + end +end diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index 9d070061850..3d966848c5b 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -312,6 +312,17 @@ RSpec.describe ProjectsController do expect { get_show }.not_to change { Gitlab::GitalyClient.get_request_count } end + + it "renders files even with invalid license" do + controller.instance_variable_set(:@project, public_project) + expect(public_project.repository).to receive(:license_key).and_return('woozle wuzzle').at_least(:once) + + get_show + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template('_files') + expect(response.body).to have_content('LICENSE') # would be 'MIT license' if stub not works + end end context "when the url contains .atom" do @@ -409,42 +420,66 @@ RSpec.describe ProjectsController do end describe 'POST create' do - let!(:params) do - { - path: 'foo', - description: 'bar', - import_url: project.http_url_to_repo, - namespace_id: user.namespace.id - } - end - subject { post :create, params: { project: params } } before do sign_in(user) end - context 'when import by url is disabled' do - before do - stub_application_setting(import_sources: []) + context 'on import' do + let(:params) do + { + path: 'foo', + description: 'bar', + namespace_id: user.namespace.id, + import_url: project.http_url_to_repo + } + end + + context 'when import by url is disabled' do + before do + stub_application_setting(import_sources: []) + end + + it 'does not create project and reports an error' do + expect { subject }.not_to change { Project.count } + + expect(response).to have_gitlab_http_status(:not_found) + end end - it 'does not create project and reports an error' do - expect { subject }.not_to change { Project.count } + context 'when import by url is enabled' do + before do + stub_application_setting(import_sources: ['git']) + end + + it 'creates project' do + expect { subject }.to change { Project.count } - expect(response).to have_gitlab_http_status(:not_found) + expect(response).to have_gitlab_http_status(:redirect) + end end end - context 'when import by url is enabled' do - before do - stub_application_setting(import_sources: ['git']) + context 'with new_project_sast_enabled', :experiment do + let(:params) do + { + path: 'foo', + description: 'bar', + namespace_id: user.namespace.id, + initialize_with_sast: '1' + } end - it 'creates project' do - expect { subject }.to change { Project.count } + it 'tracks an event on project creation' do + expect(experiment(:new_project_sast_enabled)).to track(:created, + property: 'blank', + checked: true, + project: an_instance_of(Project), + namespace: user.namespace + ).on_next_instance.with_context(user: user) - expect(response).to have_gitlab_http_status(:redirect) + post :create, params: { project: params } end end end @@ -1373,12 +1408,12 @@ RSpec.describe ProjectsController do end end - context 'when the endpoint receives requests above the limit', :clean_gitlab_redis_cache do + context 'when the endpoint receives requests above the limit', :clean_gitlab_redis_rate_limiting do include_examples 'rate limits project export endpoint' end end - describe '#download_export', :clean_gitlab_redis_cache do + describe '#download_export', :clean_gitlab_redis_rate_limiting do let(:action) { :download_export } context 'object storage enabled' do @@ -1413,7 +1448,7 @@ RSpec.describe ProjectsController do end end - context 'when the endpoint receives requests above the limit', :clean_gitlab_redis_cache do + context 'when the endpoint receives requests above the limit', :clean_gitlab_redis_rate_limiting do before do allow(Gitlab::ApplicationRateLimiter) .to receive(:increment) @@ -1485,7 +1520,7 @@ RSpec.describe ProjectsController do end end - context 'when the endpoint receives requests above the limit', :clean_gitlab_redis_cache do + context 'when the endpoint receives requests above the limit', :clean_gitlab_redis_rate_limiting do include_examples 'rate limits project export endpoint' end end diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb index 5edd60ebc79..a25c597edb2 100644 --- a/spec/controllers/registrations_controller_spec.rb +++ b/spec/controllers/registrations_controller_spec.rb @@ -602,6 +602,22 @@ RSpec.describe RegistrationsController do end end + context 'when user did not accept app terms' do + let(:user) { create(:user, accepted_term: nil) } + + before do + stub_application_setting(password_authentication_enabled_for_web: false) + stub_application_setting(password_authentication_enabled_for_git: false) + stub_application_setting(enforce_terms: true) + end + + it 'fails with message' do + post :destroy, params: { username: user.username } + + expect_failure(s_('Profiles|You must accept the Terms of Service in order to perform this action.')) + end + end + it 'sets the username and caller_id in the context' do expect(controller).to receive(:destroy).and_wrap_original do |m, *args| m.call(*args) diff --git a/spec/controllers/repositories/git_http_controller_spec.rb b/spec/controllers/repositories/git_http_controller_spec.rb index 04d5008cb34..b5cd14154a3 100644 --- a/spec/controllers/repositories/git_http_controller_spec.rb +++ b/spec/controllers/repositories/git_http_controller_spec.rb @@ -7,12 +7,33 @@ RSpec.describe Repositories::GitHttpController do let_it_be(:personal_snippet) { create(:personal_snippet, :public, :repository) } let_it_be(:project_snippet) { create(:project_snippet, :public, :repository, project: project) } + shared_examples 'handles unavailable Gitaly' do + let(:params) { super().merge(service: 'git-upload-pack') } + + before do + request.headers.merge! auth_env(user.username, user.password, nil) + end + + context 'when Gitaly is unavailable' do + it 'responds with a 503 message' do + expect(Gitlab::GitalyClient).to receive(:call).and_raise(GRPC::Unavailable) + + get :info_refs, params: params + + expect(response).to have_gitlab_http_status(:service_unavailable) + expect(response.body).to eq('The git server, Gitaly, is not available at this time. Please contact your administrator.') + end + end + end + context 'when repository container is a project' do it_behaves_like Repositories::GitHttpController do let(:container) { project } let(:user) { project.owner } let(:access_checker_class) { Gitlab::GitAccess } + it_behaves_like 'handles unavailable Gitaly' + describe 'POST #git_upload_pack' do before do allow(controller).to receive(:verify_workhorse_api!).and_return(true) @@ -84,6 +105,8 @@ RSpec.describe Repositories::GitHttpController do let(:container) { personal_snippet } let(:user) { personal_snippet.author } let(:access_checker_class) { Gitlab::GitAccessSnippet } + + it_behaves_like 'handles unavailable Gitaly' end end @@ -92,6 +115,8 @@ RSpec.describe Repositories::GitHttpController do let(:container) { project_snippet } let(:user) { project_snippet.author } let(:access_checker_class) { Gitlab::GitAccessSnippet } + + it_behaves_like 'handles unavailable Gitaly' end end end diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb index 4e87a9fc1ba..73e8e0c7dd4 100644 --- a/spec/controllers/search_controller_spec.rb +++ b/spec/controllers/search_controller_spec.rb @@ -215,6 +215,16 @@ RSpec.describe SearchController do end end + it 'strips surrounding whitespace from search query' do + get :show, params: { scope: 'notes', search: ' foobar ' } + expect(assigns[:search_term]).to eq 'foobar' + end + + it 'strips surrounding whitespace from autocomplete term' do + expect(controller).to receive(:search_autocomplete_opts).with('youcompleteme') + get :autocomplete, params: { term: ' youcompleteme ' } + end + it 'finds issue comments' do project = create(:project, :public) note = create(:note_on_issue, project: project) @@ -305,7 +315,7 @@ RSpec.describe SearchController do expect(response).to have_gitlab_http_status(:ok) - expect(response.headers['Cache-Control']).to eq('no-store') + expect(response.headers['Cache-Control']).to eq('private, no-store') end end diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index 2aa9b86b20e..8442c214cd3 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -599,6 +599,46 @@ RSpec.describe UploadsController do end end + context "when viewing a topic avatar" do + let!(:topic) { create(:topic, avatar: fixture_file_upload("spec/fixtures/dk.png", "image/png")) } + + context "when signed in" do + before do + sign_in(user) + end + + it "responds with status 200" do + get :show, params: { model: "projects/topic", mounted_as: "avatar", id: topic.id, filename: "dk.png" } + + expect(response).to have_gitlab_http_status(:ok) + end + + it_behaves_like 'content publicly cached' do + subject do + get :show, params: { model: "projects/topic", mounted_as: "avatar", id: topic.id, filename: "dk.png" } + + response + end + end + end + + context "when not signed in" do + it "responds with status 200" do + get :show, params: { model: "projects/topic", mounted_as: "avatar", id: topic.id, filename: "dk.png" } + + expect(response).to have_gitlab_http_status(:ok) + end + + it_behaves_like 'content publicly cached' do + subject do + get :show, params: { model: "projects/topic", mounted_as: "avatar", id: topic.id, filename: "dk.png" } + + response + end + end + end + end + context 'Appearance' do context 'when viewing a custom header logo' do let!(:appearance) { create :appearance, header_logo: fixture_file_upload('spec/fixtures/dk.png', 'image/png') } diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb index c7739e2ff5f..5eccb0b46ef 100644 --- a/spec/db/schema_spec.rb +++ b/spec/db/schema_spec.rb @@ -67,6 +67,8 @@ RSpec.describe 'Database schema' do oauth_access_tokens: %w[resource_owner_id application_id], oauth_applications: %w[owner_id], open_project_tracker_data: %w[closed_status_id], + packages_build_infos: %w[pipeline_id], + packages_package_file_build_infos: %w[pipeline_id], product_analytics_events_experimental: %w[event_id txn_id user_id], project_group_links: %w[group_id], project_statistics: %w[namespace_id], @@ -82,6 +84,7 @@ RSpec.describe 'Database schema' do subscriptions: %w[user_id subscribable_id], suggestions: %w[commit_id], taggings: %w[tag_id taggable_id tagger_id], + terraform_state_versions: %w[ci_build_id], timelogs: %w[user_id], todos: %w[target_id commit_id], uploads: %w[model_id], @@ -201,7 +204,8 @@ RSpec.describe 'Database schema' do "Operations::FeatureFlags::Strategy" => %w[parameters], "Packages::Composer::Metadatum" => %w[composer_json], "RawUsageData" => %w[payload], # Usage data payload changes often, we cannot use one schema - "Releases::Evidence" => %w[summary] + "Releases::Evidence" => %w[summary], + "Vulnerabilities::Finding::Evidence" => %w[data] # Validation work in progress }.freeze # We are skipping GEO models for now as it adds up complexity diff --git a/spec/experiments/new_project_sast_enabled_experiment_spec.rb b/spec/experiments/new_project_sast_enabled_experiment_spec.rb new file mode 100644 index 00000000000..dcf71bfffd7 --- /dev/null +++ b/spec/experiments/new_project_sast_enabled_experiment_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe NewProjectSastEnabledExperiment do + it "defines the expected behaviors and variants" do + expect(subject.behaviors.keys).to match_array(%w[control candidate free_indicator]) + end + + it "publishes to the database" do + expect(subject).to receive(:publish_to_database) + + subject.publish + end +end diff --git a/spec/factories/bulk_import.rb b/spec/factories/bulk_import.rb index 07907bab3df..748afc0c67c 100644 --- a/spec/factories/bulk_import.rb +++ b/spec/factories/bulk_import.rb @@ -4,6 +4,7 @@ FactoryBot.define do factory :bulk_import, class: 'BulkImport' do user source_type { :gitlab } + source_version { BulkImport.min_gl_version_for_project_migration.to_s } trait :created do status { 0 } diff --git a/spec/factories/ci/pending_builds.rb b/spec/factories/ci/pending_builds.rb index 31e42e1bc9e..28258b0339f 100644 --- a/spec/factories/ci/pending_builds.rb +++ b/spec/factories/ci/pending_builds.rb @@ -9,5 +9,6 @@ FactoryBot.define do namespace { project.namespace } minutes_exceeded { false } tag_ids { build.tags_ids } + namespace_traversal_ids { project.namespace.traversal_ids } end end diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index 4fc7d945881..ae3404a41a2 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -18,6 +18,10 @@ FactoryBot.define do transient { child_of { nil } } transient { upstream_of { nil } } + before(:create) do |pipeline, evaluator| + pipeline.ensure_project_iid! + end + after(:build) do |pipeline, evaluator| if evaluator.child_of pipeline.project = evaluator.child_of.project diff --git a/spec/factories/ci/runner_projects.rb b/spec/factories/ci/runner_projects.rb index ead9fe10f6e..31536275ff4 100644 --- a/spec/factories/ci/runner_projects.rb +++ b/spec/factories/ci/runner_projects.rb @@ -2,7 +2,14 @@ FactoryBot.define do factory :ci_runner_project, class: 'Ci::RunnerProject' do - runner factory: [:ci_runner, :project] project + + after(:build) do |runner_project, evaluator| + unless runner_project.runner.present? + runner_project.runner = build( + :ci_runner, :project, runner_projects: [runner_project] + ) + end + end end end diff --git a/spec/factories/ci/runners.rb b/spec/factories/ci/runners.rb index 30f78531324..d0853df4e4b 100644 --- a/spec/factories/ci/runners.rb +++ b/spec/factories/ci/runners.rb @@ -10,6 +10,16 @@ FactoryBot.define do runner_type { :instance_type } + transient do + projects { [] } + end + + after(:build) do |runner, evaluator| + evaluator.projects.each do |proj| + runner.runner_projects << build(:ci_runner_project, project: proj) + end + end + trait :online do contacted_at { Time.now } end @@ -30,7 +40,9 @@ FactoryBot.define do runner_type { :project_type } after(:build) do |runner, evaluator| - runner.projects << build(:project) if runner.projects.empty? + if runner.runner_projects.empty? + runner.runner_projects << build(:ci_runner_project) + end end end diff --git a/spec/factories/dependency_proxy.rb b/spec/factories/dependency_proxy.rb index c2873ce9b5e..836ee87e4d7 100644 --- a/spec/factories/dependency_proxy.rb +++ b/spec/factories/dependency_proxy.rb @@ -6,6 +6,11 @@ FactoryBot.define do size { 1234 } file { fixture_file_upload('spec/fixtures/dependency_proxy/a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4.gz') } file_name { 'a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4.gz' } + status { :default } + + trait :expired do + status { :expired } + end end factory :dependency_proxy_manifest, class: 'DependencyProxy::Manifest' do @@ -13,7 +18,12 @@ FactoryBot.define do size { 1234 } file { fixture_file_upload('spec/fixtures/dependency_proxy/manifest') } digest { 'sha256:d0710affa17fad5f466a70159cc458227bd25d4afb39514ef662ead3e6c99515' } - file_name { 'alpine:latest.json' } + sequence(:file_name) { |n| "alpine:latest#{n}.json" } content_type { 'application/vnd.docker.distribution.manifest.v2+json' } + status { :default } + + trait :expired do + status { :expired } + end end end diff --git a/spec/factories/dependency_proxy/group_settings.rb b/spec/factories/dependency_proxy/group_settings.rb new file mode 100644 index 00000000000..c15cddf7430 --- /dev/null +++ b/spec/factories/dependency_proxy/group_settings.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :dependency_proxy_group_setting, class: 'DependencyProxy::GroupSetting' do + group + + enabled { true } + end +end diff --git a/spec/factories/dependency_proxy/image_ttl_group_policies.rb b/spec/factories/dependency_proxy/image_ttl_group_policies.rb index 21e5dd44cf5..068c87f578c 100644 --- a/spec/factories/dependency_proxy/image_ttl_group_policies.rb +++ b/spec/factories/dependency_proxy/image_ttl_group_policies.rb @@ -6,5 +6,9 @@ FactoryBot.define do enabled { true } ttl { 90 } + + trait :disabled do + enabled { false } + end end end diff --git a/spec/factories/design_management/versions.rb b/spec/factories/design_management/versions.rb index 247a385bd0e..e505a77d6bd 100644 --- a/spec/factories/design_management/versions.rb +++ b/spec/factories/design_management/versions.rb @@ -52,9 +52,9 @@ FactoryBot.define do .where(design_id: evaluator.deleted_designs.map(&:id)) .update_all(event: events[:deletion]) - version.designs.reload # Ensure version.issue == design.issue for all version.designs version.designs.update_all(issue_id: version.issue_id) + version.designs.reload needed = evaluator.designs_count have = version.designs.size diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb index bd6e37c1cef..859f381e4c1 100644 --- a/spec/factories/groups.rb +++ b/spec/factories/groups.rb @@ -4,7 +4,7 @@ FactoryBot.define do factory :group, class: 'Group', parent: :namespace do sequence(:name) { |n| "group#{n}" } path { name.downcase.gsub(/\s/, '_') } - type { 'Group' } + type { Group.sti_name } owner { nil } project_creation_level { ::Gitlab::Access::MAINTAINER_PROJECT_ACCESS } @@ -69,6 +69,20 @@ FactoryBot.define do allow_descendants_override_disabled_shared_runners { true } end + trait :disabled_and_unoverridable do + shared_runners_disabled + allow_descendants_override_disabled_shared_runners { false } + end + + trait :disabled_with_override do + shared_runners_disabled + allow_descendants_override_disabled_shared_runners + end + + trait :shared_runners_enabled do + shared_runners_enabled { true } + end + # Construct a hierarchy underneath the group. # Each group will have `children` amount of children, # and `depth` levels of descendants. diff --git a/spec/factories/integration_data.rb b/spec/factories/integration_data.rb index 4d0892556f8..7ff2f3ae846 100644 --- a/spec/factories/integration_data.rb +++ b/spec/factories/integration_data.rb @@ -18,12 +18,4 @@ FactoryBot.define do factory :issue_tracker_data, class: 'Integrations::IssueTrackerData' do integration end - - factory :open_project_tracker_data, class: 'Integrations::OpenProjectTrackerData' do - integration factory: :open_project_service - url { 'http://openproject.example.com' } - token { 'supersecret' } - project_identifier_code { 'PRJ-1' } - closed_status_id { '15' } - end end diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb index cb1c94c25c1..63f85c04ac7 100644 --- a/spec/factories/integrations.rb +++ b/spec/factories/integrations.rb @@ -166,26 +166,6 @@ FactoryBot.define do external_wiki_url { 'http://external-wiki-url.com' } end - factory :open_project_service, class: 'Integrations::OpenProject' do - project - active { true } - - transient do - url { 'http://openproject.example.com' } - api_url { 'http://openproject.example.com/issues/:id' } - token { 'supersecret' } - closed_status_id { '15' } - project_identifier_code { 'PRJ-1' } - end - - after(:build) do |integration, evaluator| - integration.open_project_tracker_data = build(:open_project_tracker_data, - integration: integration, url: evaluator.url, api_url: evaluator.api_url, token: evaluator.token, - closed_status_id: evaluator.closed_status_id, project_identifier_code: evaluator.project_identifier_code - ) - end - end - trait :jira_cloud_service do url { 'https://mysite.atlassian.net' } username { 'jira_user' } diff --git a/spec/factories/namespaces.rb b/spec/factories/namespaces.rb index 957ec88420d..959183f227d 100644 --- a/spec/factories/namespaces.rb +++ b/spec/factories/namespaces.rb @@ -5,6 +5,8 @@ FactoryBot.define do sequence(:name) { |n| "namespace#{n}" } path { name.downcase.gsub(/\s/, '_') } + # TODO: can this be moved into the :user_namespace factory? + # evaluate in issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070 owner { association(:user, strategy: :build, namespace: instance, username: path) } trait :with_aggregation_schedule do diff --git a/spec/factories/namespaces/project_namespaces.rb b/spec/factories/namespaces/project_namespaces.rb index 10b86f48090..ca9fc5f8768 100644 --- a/spec/factories/namespaces/project_namespaces.rb +++ b/spec/factories/namespaces/project_namespaces.rb @@ -3,10 +3,11 @@ FactoryBot.define do factory :project_namespace, class: 'Namespaces::ProjectNamespace' do project + parent { project.namespace } + visibility_level { project.visibility_level } name { project.name } path { project.path } type { Namespaces::ProjectNamespace.sti_name } owner { nil } - parent factory: :group end end diff --git a/spec/factories/namespaces/user_namespaces.rb b/spec/factories/namespaces/user_namespaces.rb new file mode 100644 index 00000000000..31c924462d7 --- /dev/null +++ b/spec/factories/namespaces/user_namespaces.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :user_namespace, class: 'Namespaces::UserNamespace', parent: :namespace do + sequence(:name) { |n| "user_namespace#{n}" } + type { Namespaces::UserNamespace.sti_name } + end +end diff --git a/spec/factories/packages/build_info.rb b/spec/factories/packages/build_infos.rb index dc6208d72a9..dc6208d72a9 100644 --- a/spec/factories/packages/build_info.rb +++ b/spec/factories/packages/build_infos.rb diff --git a/spec/factories/packages/composer/cache_files.rb b/spec/factories/packages/composer/cache_files.rb new file mode 100644 index 00000000000..30c28ec175b --- /dev/null +++ b/spec/factories/packages/composer/cache_files.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :composer_cache_file, class: 'Packages::Composer::CacheFile' do + group + + file_sha256 { '1' * 64 } + + transient do + file_fixture { 'spec/fixtures/packages/composer/package.json' } + end + + after(:build) do |cache_file, evaluator| + cache_file.file = fixture_file_upload(evaluator.file_fixture) + end + + trait(:object_storage) do + file_store { Packages::Composer::CacheUploader::Store::REMOTE } + end + end +end diff --git a/spec/factories/packages/composer/metadata.rb b/spec/factories/packages/composer/metadata.rb new file mode 100644 index 00000000000..e4b38faa9ba --- /dev/null +++ b/spec/factories/packages/composer/metadata.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :composer_metadatum, class: 'Packages::Composer::Metadatum' do + package { association(:composer_package) } + + target_sha { '123' } + composer_json { { name: 'foo' } } + end +end diff --git a/spec/factories/packages/conan/file_metadata.rb b/spec/factories/packages/conan/file_metadata.rb new file mode 100644 index 00000000000..609f80e54c7 --- /dev/null +++ b/spec/factories/packages/conan/file_metadata.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :conan_file_metadatum, class: 'Packages::Conan::FileMetadatum' do + package_file { association(:conan_package_file, :conan_recipe_file, without_loaded_metadatum: true) } + recipe_revision { '0' } + conan_file_type { 'recipe_file' } + + trait(:recipe_file) do + conan_file_type { 'recipe_file' } + end + + trait(:package_file) do + package_file { association(:conan_package_file, :conan_package, without_loaded_metadatum: true) } + conan_file_type { 'package_file' } + package_revision { '0' } + conan_package_reference { '123456789' } + end + end +end diff --git a/spec/factories/packages/conan/metadata.rb b/spec/factories/packages/conan/metadata.rb new file mode 100644 index 00000000000..81ded799684 --- /dev/null +++ b/spec/factories/packages/conan/metadata.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :conan_metadatum, class: 'Packages::Conan::Metadatum' do + association :package, factory: [:conan_package, :without_loaded_metadatum], without_package_files: true + package_username { 'username' } + package_channel { 'stable' } + end +end diff --git a/spec/factories/packages/dependencies.rb b/spec/factories/packages/dependencies.rb new file mode 100644 index 00000000000..a62d48c2e73 --- /dev/null +++ b/spec/factories/packages/dependencies.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +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 +end diff --git a/spec/factories/packages/dependency_links.rb b/spec/factories/packages/dependency_links.rb new file mode 100644 index 00000000000..6470cbdc9a6 --- /dev/null +++ b/spec/factories/packages/dependency_links.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :packages_dependency_link, class: 'Packages::DependencyLink' do + package { association(:nuget_package) } + dependency { association(:packages_dependency) } + dependency_type { :dependencies } + + trait(:with_nuget_metadatum) do + after :build do |link| + link.nuget_metadatum = build(:nuget_dependency_link_metadatum) + end + end + + trait(:rubygems) do + package { association(:rubygems_package) } + dependency { association(:packages_dependency, :rubygems) } + end + end +end diff --git a/spec/factories/packages/maven/maven_metadata.rb b/spec/factories/packages/maven/maven_metadata.rb new file mode 100644 index 00000000000..861daab3a74 --- /dev/null +++ b/spec/factories/packages/maven/maven_metadata.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :maven_metadatum, class: 'Packages::Maven::Metadatum' do + association :package, package_type: :maven + path { 'my/company/app/my-app/1.0-SNAPSHOT' } + app_group { 'my.company.app' } + app_name { 'my-app' } + app_version { '1.0-SNAPSHOT' } + end +end diff --git a/spec/factories/packages/nuget/dependency_link_metadata.rb b/spec/factories/packages/nuget/dependency_link_metadata.rb new file mode 100644 index 00000000000..ed632e72cbf --- /dev/null +++ b/spec/factories/packages/nuget/dependency_link_metadata.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :nuget_dependency_link_metadatum, class: 'Packages::Nuget::DependencyLinkMetadatum' do + dependency_link { association(:packages_dependency_link) } + target_framework { '.NETStandard2.0' } + end +end diff --git a/spec/factories/packages/nuget/metadata.rb b/spec/factories/packages/nuget/metadata.rb new file mode 100644 index 00000000000..d2a2a666928 --- /dev/null +++ b/spec/factories/packages/nuget/metadata.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :nuget_metadatum, class: 'Packages::Nuget::Metadatum' do + package { association(:nuget_package) } + + license_url { 'http://www.gitlab.com' } + project_url { 'http://www.gitlab.com' } + icon_url { 'http://www.gitlab.com' } + end +end diff --git a/spec/factories/packages/package_file.rb b/spec/factories/packages/package_files.rb index d9afbac1048..d9afbac1048 100644 --- a/spec/factories/packages/package_file.rb +++ b/spec/factories/packages/package_files.rb diff --git a/spec/factories/packages/package_tags.rb b/spec/factories/packages/package_tags.rb new file mode 100644 index 00000000000..3d2eea4a73b --- /dev/null +++ b/spec/factories/packages/package_tags.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :packages_tag, class: 'Packages::Tag' do + package + sequence(:name) { |n| "tag-#{n}"} + end +end diff --git a/spec/factories/packages.rb b/spec/factories/packages/packages.rb index b04b7e691fe..bb9aa95fe08 100644 --- a/spec/factories/packages.rb +++ b/spec/factories/packages/packages.rb @@ -249,117 +249,4 @@ FactoryBot.define do package_type { :generic } end end - - factory :composer_metadatum, class: 'Packages::Composer::Metadatum' do - package { association(:composer_package) } - - target_sha { '123' } - composer_json { { name: 'foo' } } - end - - factory :composer_cache_file, class: 'Packages::Composer::CacheFile' do - group - - file_sha256 { '1' * 64 } - - transient do - file_fixture { 'spec/fixtures/packages/composer/package.json' } - end - - after(:build) do |cache_file, evaluator| - cache_file.file = fixture_file_upload(evaluator.file_fixture) - end - - trait(:object_storage) do - file_store { Packages::Composer::CacheUploader::Store::REMOTE } - end - end - - factory :maven_metadatum, class: 'Packages::Maven::Metadatum' do - association :package, package_type: :maven - path { 'my/company/app/my-app/1.0-SNAPSHOT' } - app_group { 'my.company.app' } - app_name { 'my-app' } - app_version { '1.0-SNAPSHOT' } - end - - factory :conan_metadatum, class: 'Packages::Conan::Metadatum' do - association :package, factory: [:conan_package, :without_loaded_metadatum], without_package_files: true - package_username { 'username' } - package_channel { 'stable' } - end - - factory :pypi_metadatum, class: 'Packages::Pypi::Metadatum' do - package { association(:pypi_package, without_loaded_metadatum: true) } - required_python { '>=2.7' } - end - - factory :nuget_metadatum, class: 'Packages::Nuget::Metadatum' do - package { association(:nuget_package) } - - license_url { 'http://www.gitlab.com' } - project_url { 'http://www.gitlab.com' } - icon_url { 'http://www.gitlab.com' } - end - - factory :conan_file_metadatum, class: 'Packages::Conan::FileMetadatum' do - package_file { association(:conan_package_file, :conan_recipe_file, without_loaded_metadatum: true) } - recipe_revision { '0' } - conan_file_type { 'recipe_file' } - - trait(:recipe_file) do - conan_file_type { 'recipe_file' } - end - - trait(:package_file) do - package_file { association(:conan_package_file, :conan_package, without_loaded_metadatum: true) } - conan_file_type { 'package_file' } - package_revision { '0' } - conan_package_reference { '123456789' } - end - end - - 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 - package { association(:nuget_package) } - dependency { association(:packages_dependency) } - dependency_type { :dependencies } - - trait(:with_nuget_metadatum) do - after :build do |link| - 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 - dependency_link { association(:packages_dependency_link) } - target_framework { '.NETStandard2.0' } - end - - factory :packages_tag, class: 'Packages::Tag' do - package - sequence(:name) { |n| "tag-#{n}"} - end - - factory :packages_build_info, class: 'Packages::BuildInfo' do - package - - trait :with_pipeline do - association :pipeline, factory: [:ci_pipeline, :with_job] - end - end end diff --git a/spec/factories/packages/pypi/metadata.rb b/spec/factories/packages/pypi/metadata.rb new file mode 100644 index 00000000000..00abe403bd1 --- /dev/null +++ b/spec/factories/packages/pypi/metadata.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :pypi_metadatum, class: 'Packages::Pypi::Metadatum' do + package { association(:pypi_package, without_loaded_metadatum: true) } + required_python { '>=2.7' } + end +end diff --git a/spec/factories/pages_domains.rb b/spec/factories/pages_domains.rb index 2ba5cbb48bf..f3f2af79b76 100644 --- a/spec/factories/pages_domains.rb +++ b/spec/factories/pages_domains.rb @@ -258,6 +258,18 @@ ZDXgrA== certificate_source { :gitlab_provided } end + # This contains: + # webdioxide.com + # Let's Encrypt R3 + # ISRG Root X1 (issued by DST Root CA X3) + # + # DST Root CA X3 expired on 2021-09-30, but ISRG Root X1 should be trusted on most systems. + trait :letsencrypt_expired_x3_root do + certificate do + File.read(Rails.root.join('spec/fixtures/ssl', 'letsencrypt_expired_x3.pem')) + end + end + trait :explicit_ecdsa do certificate do '-----BEGIN CERTIFICATE----- diff --git a/spec/factories/project_error_tracking_settings.rb b/spec/factories/project_error_tracking_settings.rb index 424f462e1a0..ed743d8283c 100644 --- a/spec/factories/project_error_tracking_settings.rb +++ b/spec/factories/project_error_tracking_settings.rb @@ -8,6 +8,7 @@ FactoryBot.define do token { 'access_token_123' } project_name { 'Sentry Project' } organization_name { 'Sentry Org' } + integrated { false } trait :disabled do enabled { false } diff --git a/spec/factories/usage_data.rb b/spec/factories/usage_data.rb index c02bcfc2169..fc1f5d71f39 100644 --- a/spec/factories/usage_data.rb +++ b/spec/factories/usage_data.rb @@ -88,17 +88,9 @@ FactoryBot.define do create(:cluster, :group, :disabled) create(:cluster, :instance, :disabled) - # Applications - create(:clusters_applications_helm, :installed, cluster: gcp_cluster) - create(:clusters_applications_ingress, :installed, cluster: gcp_cluster) - create(:clusters_applications_cert_manager, :installed, cluster: gcp_cluster) - create(:clusters_applications_prometheus, :installed, cluster: gcp_cluster) - create(:clusters_applications_crossplane, :installed, cluster: gcp_cluster) - create(:clusters_applications_runner, :installed, cluster: gcp_cluster) - create(:clusters_applications_knative, :installed, cluster: gcp_cluster) - create(:clusters_applications_elastic_stack, :installed, cluster: gcp_cluster) - create(:clusters_applications_jupyter, :installed, cluster: gcp_cluster) - create(:clusters_applications_cilium, :installed, cluster: gcp_cluster) + # Cluster Integrations + create(:clusters_integrations_prometheus, cluster: gcp_cluster) + create(:clusters_integrations_elastic_stack, cluster: gcp_cluster) create(:grafana_integration, project: projects[0], enabled: true) create(:grafana_integration, project: projects[1], enabled: true) diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 04bacbe14e7..325f62f6028 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -139,6 +139,8 @@ FactoryBot.define do end factory :omniauth_user do + password_automatically_set { true } + transient do extern_uid { '123456' } provider { 'ldapmain' } diff --git a/spec/factories_spec.rb b/spec/factories_spec.rb index 6c7c3776c4a..7dc38b25fac 100644 --- a/spec/factories_spec.rb +++ b/spec/factories_spec.rb @@ -29,6 +29,7 @@ RSpec.describe 'factories' do [:pages_domain, :with_trusted_chain], [:pages_domain, :with_trusted_expired_chain], [:pages_domain, :explicit_ecdsa], + [:pages_domain, :letsencrypt_expired_x3_root], [:project_member, :blocked], [:remote_mirror, :ssh], [:user_preference, :only_comments], diff --git a/spec/features/admin/admin_appearance_spec.rb b/spec/features/admin/admin_appearance_spec.rb index cd148642b90..cb69eac8035 100644 --- a/spec/features/admin/admin_appearance_spec.rb +++ b/spec/features/admin/admin_appearance_spec.rb @@ -34,6 +34,10 @@ RSpec.describe 'Admin Appearance' do visit admin_application_settings_appearances_path click_link "Sign-in page" + expect(find('#login')).to be_disabled + expect(find('#password')).to be_disabled + expect(find('button')).to be_disabled + expect_custom_sign_in_appearance(appearance) end diff --git a/spec/features/admin/admin_hook_logs_spec.rb b/spec/features/admin/admin_hook_logs_spec.rb index 3f63bf9a15c..837cab49bd4 100644 --- a/spec/features/admin/admin_hook_logs_spec.rb +++ b/spec/features/admin/admin_hook_logs_spec.rb @@ -17,8 +17,8 @@ RSpec.describe 'Admin::HookLogs' do hook_log visit edit_admin_hook_path(system_hook) - expect(page).to have_content('Recent Deliveries') - expect(page).to have_content(hook_log.url) + expect(page).to have_content('Recent events') + expect(page).to have_link('View details', href: admin_hook_hook_log_path(system_hook, hook_log)) end it 'show hook log details' do diff --git a/spec/features/admin/admin_builds_spec.rb b/spec/features/admin/admin_jobs_spec.rb index 42827dd5b49..36822f89c12 100644 --- a/spec/features/admin/admin_builds_spec.rb +++ b/spec/features/admin/admin_jobs_spec.rb @@ -2,14 +2,14 @@ require 'spec_helper' -RSpec.describe 'Admin Builds' do +RSpec.describe 'Admin Jobs' do before do admin = create(:admin) sign_in(admin) gitlab_enable_admin_mode_sign_in(admin) end - describe 'GET /admin/builds' do + describe 'GET /admin/jobs' do let(:pipeline) { create(:ci_pipeline) } context 'All tab' do @@ -22,7 +22,7 @@ RSpec.describe 'Admin Builds' do visit admin_jobs_path - expect(page).to have_selector('.nav-links li.active', text: 'All') + expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'All') expect(page).to have_selector('.row-content-block', text: 'All jobs') expect(page.all('.build-link').size).to eq(4) expect(page).to have_button 'Stop all jobs' @@ -37,7 +37,7 @@ RSpec.describe 'Admin Builds' do it 'shows a message' do visit admin_jobs_path - expect(page).to have_selector('.nav-links li.active', text: 'All') + expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'All') expect(page).to have_content 'No jobs to show' expect(page).not_to have_button 'Stop all jobs' end @@ -54,7 +54,7 @@ RSpec.describe 'Admin Builds' do visit admin_jobs_path(scope: :pending) - expect(page).to have_selector('.nav-links li.active', text: 'Pending') + expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Pending') expect(page.find('.build-link')).to have_content(build1.id) expect(page.find('.build-link')).not_to have_content(build2.id) expect(page.find('.build-link')).not_to have_content(build3.id) @@ -69,7 +69,7 @@ RSpec.describe 'Admin Builds' do visit admin_jobs_path(scope: :pending) - expect(page).to have_selector('.nav-links li.active', text: 'Pending') + expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Pending') expect(page).to have_content 'No jobs to show' expect(page).not_to have_button 'Stop all jobs' end @@ -86,7 +86,7 @@ RSpec.describe 'Admin Builds' do visit admin_jobs_path(scope: :running) - expect(page).to have_selector('.nav-links li.active', text: 'Running') + expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Running') expect(page.find('.build-link')).to have_content(build1.id) expect(page.find('.build-link')).not_to have_content(build2.id) expect(page.find('.build-link')).not_to have_content(build3.id) @@ -101,7 +101,7 @@ RSpec.describe 'Admin Builds' do visit admin_jobs_path(scope: :running) - expect(page).to have_selector('.nav-links li.active', text: 'Running') + expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Running') expect(page).to have_content 'No jobs to show' expect(page).not_to have_button 'Stop all jobs' end @@ -117,7 +117,7 @@ RSpec.describe 'Admin Builds' do visit admin_jobs_path(scope: :finished) - expect(page).to have_selector('.nav-links li.active', text: 'Finished') + expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Finished') expect(page.find('.build-link')).not_to have_content(build1.id) expect(page.find('.build-link')).not_to have_content(build2.id) expect(page.find('.build-link')).to have_content(build3.id) @@ -131,7 +131,7 @@ RSpec.describe 'Admin Builds' do visit admin_jobs_path(scope: :finished) - expect(page).to have_selector('.nav-links li.active', text: 'Finished') + expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Finished') expect(page).to have_content 'No jobs to show' expect(page).to have_button 'Stop all jobs' end diff --git a/spec/features/admin/admin_mode/login_spec.rb b/spec/features/admin/admin_mode/login_spec.rb index 5b2dfdb2941..c8ee6c14499 100644 --- a/spec/features/admin/admin_mode/login_spec.rb +++ b/spec/features/admin/admin_mode/login_spec.rb @@ -121,7 +121,7 @@ RSpec.describe 'Admin Mode Login' do end context 'when logging in via omniauth' do - let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: 'my-uid', provider: 'saml')} + let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: 'my-uid', provider: 'saml', password_automatically_set: false)} let(:mock_saml_response) do File.read('spec/fixtures/authentication/saml_response.xml') end diff --git a/spec/features/admin/admin_projects_spec.rb b/spec/features/admin/admin_projects_spec.rb index 15def00f354..a50ef34d327 100644 --- a/spec/features/admin/admin_projects_spec.rb +++ b/spec/features/admin/admin_projects_spec.rb @@ -96,7 +96,7 @@ RSpec.describe "Admin::Projects" do visit admin_project_path(project) click_button 'Search for Namespace' - click_link 'group: web' + click_button 'group: web' click_button 'Transfer' expect(page).to have_content("Web / #{project.name}") diff --git a/spec/features/admin/admin_serverless_domains_spec.rb b/spec/features/admin/admin_serverless_domains_spec.rb deleted file mode 100644 index 0312e82e1ba..00000000000 --- a/spec/features/admin/admin_serverless_domains_spec.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Admin Serverless Domains', :js do - let(:sample_domain) { build(:pages_domain) } - - before do - allow(Gitlab.config.pages).to receive(:enabled).and_return(true) - admin = create(:admin) - sign_in(admin) - gitlab_enable_admin_mode_sign_in(admin) - end - - it 'add domain with certificate' do - visit admin_serverless_domains_path - - fill_in 'pages_domain[domain]', with: 'foo.com' - fill_in 'pages_domain[user_provided_certificate]', with: sample_domain.certificate - fill_in 'pages_domain[user_provided_key]', with: sample_domain.key - click_button 'Add domain' - - expect(current_path).to eq admin_serverless_domains_path - - expect(page).to have_field('pages_domain[domain]', with: 'foo.com') - expect(page).to have_field('serverless_domain_dns', with: /^\*\.foo\.com CNAME /) - expect(page).to have_field('serverless_domain_verification', with: /^_gitlab-pages-verification-code.foo.com TXT /) - expect(page).not_to have_field('pages_domain[user_provided_certificate]') - expect(page).not_to have_field('pages_domain[user_provided_key]') - - expect(page).to have_content 'Unverified' - expect(page).to have_content '/CN=test-certificate' - end - - it 'update domain certificate' do - visit admin_serverless_domains_path - - fill_in 'pages_domain[domain]', with: 'foo.com' - fill_in 'pages_domain[user_provided_certificate]', with: sample_domain.certificate - fill_in 'pages_domain[user_provided_key]', with: sample_domain.key - click_button 'Add domain' - - expect(current_path).to eq admin_serverless_domains_path - - expect(page).not_to have_field('pages_domain[user_provided_certificate]') - expect(page).not_to have_field('pages_domain[user_provided_key]') - - click_button 'Replace' - - expect(page).to have_field('pages_domain[user_provided_certificate]') - expect(page).to have_field('pages_domain[user_provided_key]') - - fill_in 'pages_domain[user_provided_certificate]', with: sample_domain.certificate - fill_in 'pages_domain[user_provided_key]', with: sample_domain.key - - click_button 'Save changes' - - expect(page).to have_content 'Domain was successfully updated' - expect(page).to have_content '/CN=test-certificate' - end - - context 'when domain exists' do - let!(:domain) { create(:pages_domain, :instance_serverless) } - - it 'displays a modal when attempting to delete a domain' do - visit admin_serverless_domains_path - - click_button 'Delete domain' - - page.within '#modal-delete-domain' do - expect(page).to have_content "You are about to delete #{domain.domain} from your instance." - expect(page).to have_link('Delete domain') - end - end - - it 'displays a modal with disabled button if unable to delete a domain' do - create(:serverless_domain_cluster, pages_domain: domain) - - visit admin_serverless_domains_path - - click_button 'Delete domain' - - page.within '#modal-delete-domain' do - expect(page).to have_content "You must disassociate #{domain.domain} from all clusters it is attached to before deletion." - expect(page).to have_link('Delete domain') - end - end - end -end diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index b25fc9f257a..1c50a7f891f 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -314,12 +314,14 @@ RSpec.describe 'Admin updates settings' do check 'Default to Auto DevOps pipeline for all projects' fill_in 'application_setting_auto_devops_domain', with: 'domain.com' uncheck 'Keep the latest artifacts for all jobs in the latest successful pipelines' + uncheck 'Enable pipeline suggestion banner' click_button 'Save changes' end expect(current_settings.auto_devops_enabled?).to be true expect(current_settings.auto_devops_domain).to eq('domain.com') expect(current_settings.keep_latest_artifact).to be false + expect(current_settings.suggest_pipeline_enabled).to be false expect(page).to have_content "Application settings saved successfully" end @@ -450,14 +452,14 @@ RSpec.describe 'Admin updates settings' do visit reporting_admin_application_settings_path page.within('.as-spam') do - fill_in 'reCAPTCHA Site Key', with: 'key' - fill_in 'reCAPTCHA Private Key', with: 'key' + fill_in 'reCAPTCHA site key', with: 'key' + fill_in 'reCAPTCHA private key', with: 'key' check 'Enable reCAPTCHA' check 'Enable reCAPTCHA for login' - fill_in 'IPs per user', with: 15 + fill_in 'IP addresses per user', with: 15 check 'Enable Spam Check via external API endpoint' fill_in 'URL of the external Spam Check endpoint', with: 'grpc://www.example.com/spamcheck' - fill_in 'Spam Check API Key', with: 'SPAM_CHECK_API_KEY' + fill_in 'Spam Check API key', with: 'SPAM_CHECK_API_KEY' click_button 'Save changes' end @@ -602,18 +604,54 @@ RSpec.describe 'Admin updates settings' do expect(current_settings.issues_create_limit).to eq(0) end - it 'changes Files API rate limits settings' do - visit network_admin_application_settings_path + shared_examples 'regular throttle rate limit settings' do + it 'changes rate limit settings' do + visit network_admin_application_settings_path - page.within('[data-testid="files-limits-settings"]') do - check 'Enable unauthenticated API request rate limit' - fill_in 'Max unauthenticated API requests per period per IP', with: 10 - click_button 'Save changes' + page.within(".#{selector}") do + check 'Enable unauthenticated API request rate limit' + fill_in 'Maximum unauthenticated API requests per rate limit period per IP', with: 12 + fill_in 'Unauthenticated API rate limit period in seconds', with: 34 + + check 'Enable authenticated API request rate limit' + fill_in 'Maximum authenticated API requests per rate limit period per user', with: 56 + fill_in 'Authenticated API rate limit period in seconds', with: 78 + + click_button 'Save changes' + end + + expect(page).to have_content "Application settings saved successfully" + + expect(current_settings).to have_attributes( + "throttle_unauthenticated_#{fragment}_enabled" => true, + "throttle_unauthenticated_#{fragment}_requests_per_period" => 12, + "throttle_unauthenticated_#{fragment}_period_in_seconds" => 34, + "throttle_authenticated_#{fragment}_enabled" => true, + "throttle_authenticated_#{fragment}_requests_per_period" => 56, + "throttle_authenticated_#{fragment}_period_in_seconds" => 78 + ) end + end - expect(page).to have_content "Application settings saved successfully" - expect(current_settings.throttle_unauthenticated_files_api_enabled).to be true - expect(current_settings.throttle_unauthenticated_files_api_requests_per_period).to eq(10) + context 'Package Registry API rate limits' do + let(:selector) { 'as-packages-limits' } + let(:fragment) { :packages_api } + + include_examples 'regular throttle rate limit settings' + end + + context 'Files API rate limits' do + let(:selector) { 'as-files-limits' } + let(:fragment) { :files_api } + + include_examples 'regular throttle rate limit settings' + end + + context 'Deprecated API rate limits' do + let(:selector) { 'as-deprecated-limits' } + let(:fragment) { :deprecated_api } + + include_examples 'regular throttle rate limit settings' end end @@ -623,8 +661,6 @@ RSpec.describe 'Admin updates settings' do end it 'change Help page' do - stub_feature_flags(help_page_documentation_redirect: true) - new_support_url = 'http://example.com/help' new_documentation_url = 'https://docs.gitlab.com' diff --git a/spec/features/boards/new_issue_spec.rb b/spec/features/boards/new_issue_spec.rb index e055e8092d4..f88d31bda88 100644 --- a/spec/features/boards/new_issue_spec.rb +++ b/spec/features/boards/new_issue_spec.rb @@ -56,7 +56,7 @@ RSpec.describe 'Issue Boards new issue', :js do end end - it 'creates new issue' do + it 'creates new issue and opens sidebar' do page.within(first('.board')) do click_button 'New issue' end @@ -68,7 +68,7 @@ RSpec.describe 'Issue Boards new issue', :js do wait_for_requests - page.within(first('.board .issue-count-badge-count')) do + page.within(first('.board [data-testid="issue-count-badge"]')) do expect(page).to have_content('1') end @@ -78,20 +78,6 @@ RSpec.describe 'Issue Boards new issue', :js do expect(page).to have_content(issue.to_reference) expect(page).to have_link(issue.title, href: /#{issue_path(issue)}/) end - end - - # TODO https://gitlab.com/gitlab-org/gitlab/-/issues/323446 - xit 'shows sidebar when creating new issue' do - page.within(first('.board')) do - click_button 'New issue' - end - - page.within(first('.board-new-issue-form')) do - find('.form-control').set('bug') - click_button 'Create issue' - end - - wait_for_requests expect(page).to have_selector('[data-testid="issue-boards-sidebar"]') end @@ -108,10 +94,6 @@ RSpec.describe 'Issue Boards new issue', :js do wait_for_requests - page.within(first('.board')) do - find('.board-card').click - end - page.within('[data-testid="sidebar-labels"]') do click_button 'Edit' diff --git a/spec/features/boards/reload_boards_on_browser_back_spec.rb b/spec/features/boards/reload_boards_on_browser_back_spec.rb index 36682036d48..6a09e3c9506 100644 --- a/spec/features/boards/reload_boards_on_browser_back_spec.rb +++ b/spec/features/boards/reload_boards_on_browser_back_spec.rb @@ -16,7 +16,7 @@ RSpec.describe 'Ensure Boards do not show stale data on browser back', :js do visit project_board_path(project, board) wait_for_requests - page.within(first('.board .issue-count-badge-count')) do + page.within(first('.board [data-testid="issue-count-badge"]')) do expect(page).to have_content('0') end end @@ -35,7 +35,7 @@ RSpec.describe 'Ensure Boards do not show stale data on browser back', :js do page.go_back wait_for_requests - page.within(first('.board .issue-count-badge-count')) do + page.within(first('.board [data-testid="issue-count-badge"]')) do expect(page).to have_content('1') end diff --git a/spec/features/boards/sidebar_labels_spec.rb b/spec/features/boards/sidebar_labels_spec.rb index fa16f47f69a..511233b50c0 100644 --- a/spec/features/boards/sidebar_labels_spec.rb +++ b/spec/features/boards/sidebar_labels_spec.rb @@ -29,12 +29,11 @@ RSpec.describe 'Project issue boards sidebar labels', :js do end context 'labels' do - # https://gitlab.com/gitlab-org/gitlab/-/issues/322725 - xit 'shows current labels when editing' do + it 'shows current labels when editing' do click_card(card) page.within('.labels') do - click_link 'Edit' + click_button 'Edit' wait_for_requests @@ -54,9 +53,9 @@ RSpec.describe 'Project issue boards sidebar labels', :js do wait_for_requests - click_link bug.title + click_on bug.title - find('[data-testid="close-icon"]').click + click_button 'Close' wait_for_requests @@ -79,11 +78,11 @@ RSpec.describe 'Project issue boards sidebar labels', :js do wait_for_requests - click_link bug.title + click_on bug.title - click_link regression.title + click_on regression.title - find('[data-testid="close-icon"]').click + click_button 'Close' wait_for_requests @@ -108,9 +107,9 @@ RSpec.describe 'Project issue boards sidebar labels', :js do wait_for_requests - click_link stretch.title + click_button stretch.title - find('[data-testid="close-icon"]').click + click_button 'Close' wait_for_requests @@ -125,43 +124,22 @@ RSpec.describe 'Project issue boards sidebar labels', :js do expect(card).not_to have_content(stretch.title) end - # https://gitlab.com/gitlab-org/gitlab/-/issues/324290 - xit 'creates project label' do + it 'creates project label' do click_card(card) page.within('.labels') do - click_link 'Edit' + click_button 'Edit' wait_for_requests - click_link 'Create project label' - fill_in 'new_label_name', with: 'test label' + click_on 'Create project label' + fill_in 'Name new label', with: 'test label' first('.suggest-colors-dropdown a').click click_button 'Create' wait_for_requests - expect(page).to have_link 'test label' + expect(page).to have_button 'test label' end expect(page).to have_selector('.board', count: 3) end - - # https://gitlab.com/gitlab-org/gitlab/-/issues/324290 - xit 'creates project label and list' do - click_card(card) - - page.within('.labels') do - click_link 'Edit' - wait_for_requests - - click_link 'Create project label' - fill_in 'new_label_name', with: 'test label' - first('.suggest-colors-dropdown a').click - first('.js-add-list').click - click_button 'Create' - wait_for_requests - - expect(page).to have_link 'test label' - end - expect(page).to have_selector('.board', count: 4) - end end end diff --git a/spec/features/clusters/cluster_detail_page_spec.rb b/spec/features/clusters/cluster_detail_page_spec.rb index cba8aaef1ef..06e3e00db7d 100644 --- a/spec/features/clusters/cluster_detail_page_spec.rb +++ b/spec/features/clusters/cluster_detail_page_spec.rb @@ -34,7 +34,7 @@ RSpec.describe 'Clusterable > Show page' do it 'does not show the environments tab' do visit cluster_path - expect(page).not_to have_selector('.js-cluster-nav-environments', text: 'Environments') + expect(page).not_to have_selector('[data-testid="cluster-environments-tab"]') end end diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb index bec474f6cfe..34a55118cb3 100644 --- a/spec/features/cycle_analytics_spec.rb +++ b/spec/features/cycle_analytics_spec.rb @@ -7,10 +7,13 @@ RSpec.describe 'Value Stream Analytics', :js do let_it_be(:guest) { create(:user) } let_it_be(:stage_table_selector) { '[data-testid="vsa-stage-table"]' } let_it_be(:stage_table_event_selector) { '[data-testid="vsa-stage-event"]' } + let_it_be(:stage_table_event_title_selector) { '[data-testid="vsa-stage-event-title"]' } + let_it_be(:stage_table_pagination_selector) { '[data-testid="vsa-stage-pagination"]' } + let_it_be(:stage_table_duration_column_header_selector) { '[data-testid="vsa-stage-header-duration"]' } let_it_be(:metrics_selector) { "[data-testid='vsa-time-metrics']" } let_it_be(:metric_value_selector) { "[data-testid='displayValue']" } - let(:stage_table) { page.find(stage_table_selector) } + let(:stage_table) { find(stage_table_selector) } let(:project) { create(:project, :repository) } let(:issue) { create(:issue, project: project, created_at: 2.days.ago) } let(:milestone) { create(:milestone, project: project) } @@ -53,6 +56,7 @@ RSpec.describe 'Value Stream Analytics', :js do # So setting the date range to be the last 2 days should skip past the existing data from = 2.days.ago.strftime("%Y-%m-%d") to = 1.day.ago.strftime("%Y-%m-%d") + max_items_per_page = 20 around do |example| travel_to(5.days.ago) { example.run } @@ -60,9 +64,8 @@ RSpec.describe 'Value Stream Analytics', :js do before do project.add_maintainer(user) - create_list(:issue, 2, project: project, created_at: 2.weeks.ago, milestone: milestone) - create_cycle(user, project, issue, mr, milestone, pipeline) + create_list(:issue, max_items_per_page, project: project, created_at: 2.weeks.ago, milestone: milestone) deploy_master(user, project) issue.metrics.update!(first_mentioned_in_commit_at: issue.metrics.first_associated_with_milestone_at + 1.hour) @@ -81,6 +84,8 @@ RSpec.describe 'Value Stream Analytics', :js do wait_for_requests end + let(:stage_table_events) { stage_table.all(stage_table_event_selector) } + it 'displays metrics' do metrics_tiles = page.find(metrics_selector) @@ -112,20 +117,62 @@ RSpec.describe 'Value Stream Analytics', :js do end it 'can filter the issues by date' do - expect(stage_table.all(stage_table_event_selector).length).to eq(3) + expect(page).to have_selector(stage_table_event_selector) set_daterange(from, to) - expect(stage_table.all(stage_table_event_selector).length).to eq(0) + expect(page).not_to have_selector(stage_table_event_selector) + expect(page).not_to have_selector(stage_table_pagination_selector) end it 'can filter the metrics by date' do - expect(metrics_values).to eq(["3.0", "2.0", "1.0", "0.0"]) + expect(metrics_values).to match_array(["21.0", "2.0", "1.0", "0.0"]) set_daterange(from, to) expect(metrics_values).to eq(['-'] * 4) end + + it 'can sort records' do + # NOTE: checking that the string changes should suffice + # depending on the order the tests are run we might run into problems with hard coded strings + original_first_title = first_stage_title + stage_time_column.click + + expect_to_be_sorted "descending" + expect(first_stage_title).not_to have_text(original_first_title, exact: true) + + stage_time_column.click + + expect_to_be_sorted "ascending" + expect(first_stage_title).to have_text(original_first_title, exact: true) + end + + it 'paginates the results' do + original_first_title = first_stage_title + + expect(page).to have_selector(stage_table_pagination_selector) + + go_to_next_page + + expect(page).not_to have_text(original_first_title, exact: true) + end + + def stage_time_column + stage_table.find(stage_table_duration_column_header_selector).ancestor("th") + end + + def first_stage_title + stage_table.all(stage_table_event_title_selector).first.text + end + + def expect_to_be_sorted(direction) + expect(stage_time_column['aria-sort']).to eq(direction) + end + + def go_to_next_page + page.find(stage_table_pagination_selector).find_link("Next").click + end end end diff --git a/spec/features/dashboard/activity_spec.rb b/spec/features/dashboard/activity_spec.rb index e75e661b513..7390edc3c47 100644 --- a/spec/features/dashboard/activity_spec.rb +++ b/spec/features/dashboard/activity_spec.rb @@ -13,19 +13,19 @@ RSpec.describe 'Dashboard > Activity' do it 'shows Your Projects' do visit activity_dashboard_path - expect(find('.top-area .nav-tabs li.active')).to have_content('Your projects') + expect(find('[data-testid="dashboard-activity-tabs"] a.active')).to have_content('Your projects') end it 'shows Starred Projects' do visit activity_dashboard_path(filter: 'starred') - expect(find('.top-area .nav-tabs li.active')).to have_content('Starred projects') + expect(find('[data-testid="dashboard-activity-tabs"] a.active')).to have_content('Starred projects') end it 'shows Followed Projects' do visit activity_dashboard_path(filter: 'followed') - expect(find('.top-area .nav-tabs li.active')).to have_content('Followed users') + expect(find('[data-testid="dashboard-activity-tabs"] a.active')).to have_content('Followed users') end end diff --git a/spec/features/dashboard/issuables_counter_spec.rb b/spec/features/dashboard/issuables_counter_spec.rb index d4c6b6faa79..8e938fef155 100644 --- a/spec/features/dashboard/issuables_counter_spec.rb +++ b/spec/features/dashboard/issuables_counter_spec.rb @@ -55,7 +55,7 @@ RSpec.describe 'Navigation bar counter', :use_clean_rails_memory_store_caching d end def expect_counters(issuable_type, count) - dashboard_count = find('.nav-links li.active') + dashboard_count = find('.gl-tabs-nav li a.active') nav_count = find(".dashboard-shortcuts-#{issuable_type}") header_count = find(".header-content .#{issuable_type.tr('_', '-')}-count") diff --git a/spec/features/groups/board_spec.rb b/spec/features/groups/board_spec.rb index afe36dabcb5..aece6d790b5 100644 --- a/spec/features/groups/board_spec.rb +++ b/spec/features/groups/board_spec.rb @@ -24,7 +24,7 @@ RSpec.describe 'Group Boards' do it 'adds an issue to the backlog' do page.within(find('.board', match: :first)) do issue_title = 'New Issue' - find(:css, '.issue-count-badge-add-button').click + click_button 'New issue' wait_for_requests diff --git a/spec/features/groups/container_registry_spec.rb b/spec/features/groups/container_registry_spec.rb index 65374263f45..098559dc3f8 100644 --- a/spec/features/groups/container_registry_spec.rb +++ b/spec/features/groups/container_registry_spec.rb @@ -16,6 +16,7 @@ RSpec.describe 'Container Registry', :js do sign_in(user) stub_container_registry_config(enabled: true) stub_container_registry_tags(repository: :any, tags: []) + stub_container_registry_info end it 'has a page title set' do @@ -57,6 +58,16 @@ RSpec.describe 'Container Registry', :js do expect(page).to have_content 'latest' end + [ContainerRegistry::Path::InvalidRegistryPathError, Faraday::Error].each do |error_class| + context "when there is a #{error_class}" do + before do + expect(::ContainerRegistry::Client).to receive(:registry_info).and_raise(error_class, nil, nil) + end + + it_behaves_like 'handling feature network errors with the container registry' + end + end + describe 'image repo details' do before do visit_container_registry_details 'my/image' @@ -81,6 +92,7 @@ RSpec.describe 'Container Registry', :js do expect(service).to receive(:execute).with(container_repository) { { status: :success } } expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['latest']) { service } + first('[data-testid="additional-actions"]').click first('[data-testid="single-delete-button"]').click expect(find('.modal .modal-title')).to have_content _('Remove tag') find('.modal .modal-footer .btn-danger').click diff --git a/spec/features/groups/dependency_proxy_for_containers_spec.rb b/spec/features/groups/dependency_proxy_for_containers_spec.rb new file mode 100644 index 00000000000..a4cd6d0f503 --- /dev/null +++ b/spec/features/groups/dependency_proxy_for_containers_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Group Dependency Proxy for containers', :js do + include DependencyProxyHelpers + + include_context 'file upload requests helpers' + + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:sha) { 'a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4' } + let_it_be(:content) { fixture_file_upload("spec/fixtures/dependency_proxy/#{sha}.gz").read } + + let(:image) { 'alpine' } + let(:url) { capybara_url("/v2/#{group.full_path}/dependency_proxy/containers/#{image}/blobs/sha256:#{sha}") } + let(:token) { 'token' } + let(:headers) { { 'Authorization' => "Bearer #{build_jwt(user).encoded}" } } + + subject do + HTTParty.get(url, headers: headers) + end + + def run_server(handler) + default_server = Capybara.server + + Capybara.server = Capybara.servers[:puma] + server = Capybara::Server.new(handler) + server.boot + server + ensure + Capybara.server = default_server + end + + let_it_be(:external_server) do + handler = lambda do |env| + if env['REQUEST_PATH'] == '/token' + [200, {}, [{ token: 'token' }.to_json]] + else + [200, {}, [content]] + end + end + + run_server(handler) + end + + before do + stub_application_setting(allow_local_requests_from_web_hooks_and_services: true) + stub_config(dependency_proxy: { enabled: true }) + group.add_developer(user) + + stub_const("DependencyProxy::Registry::AUTH_URL", external_server.base_url) + stub_const("DependencyProxy::Registry::LIBRARY_URL", external_server.base_url) + end + + shared_examples 'responds with the file' do + it 'sends file' do + expect(subject.code).to eq(200) + expect(subject.body).to eq(content) + expect(subject.headers.to_h).to include( + "content-type" => ["application/gzip"], + "content-disposition" => ["attachment; filename=\"#{sha}.gz\"; filename*=UTF-8''#{sha}.gz"], + "content-length" => ["32"] + ) + end + end + + shared_examples 'caches the file' do + it 'caches the file' do + expect { subject }.to change { + group.dependency_proxy_blobs.count + }.from(0).to(1) + + expect(subject.code).to eq(200) + expect(group.dependency_proxy_blobs.first.file.read).to eq(content) + end + end + + context 'fetching a blob' do + context 'when the blob is cached for the group' do + let!(:dependency_proxy_blob) { create(:dependency_proxy_blob, group: group) } + + it_behaves_like 'responds with the file' + + context 'dependency_proxy_workhorse feature flag disabled' do + before do + stub_feature_flags({ dependency_proxy_workhorse: false }) + end + + it_behaves_like 'responds with the file' + end + end + end + + context 'when the blob must be downloaded' do + it_behaves_like 'responds with the file' + it_behaves_like 'caches the file' + + context 'dependency_proxy_workhorse feature flag disabled' do + before do + stub_feature_flags({ dependency_proxy_workhorse: false }) + end + + it_behaves_like 'responds with the file' + it_behaves_like 'caches the file' + end + end +end diff --git a/spec/features/groups/dependency_proxy_spec.rb b/spec/features/groups/dependency_proxy_spec.rb index 51371ddc532..d6b0bdc8ea4 100644 --- a/spec/features/groups/dependency_proxy_spec.rb +++ b/spec/features/groups/dependency_proxy_spec.rb @@ -3,13 +3,14 @@ require 'spec_helper' RSpec.describe 'Group Dependency Proxy' do - let(:developer) { create(:user) } + let(:owner) { create(:user) } let(:reporter) { create(:user) } let(:group) { create(:group) } let(:path) { group_dependency_proxy_path(group) } + let(:settings_path) { group_settings_packages_and_registries_path(group) } before do - group.add_developer(developer) + group.add_owner(owner) group.add_reporter(reporter) enable_feature @@ -22,42 +23,46 @@ RSpec.describe 'Group Dependency Proxy' do visit path - expect(page).not_to have_css('.js-dependency-proxy-toggle-area') - expect(page).not_to have_css('.js-dependency-proxy-url') + expect(page).not_to have_css('[data-testid="proxy-url"]') end end context 'feature is available', :js do - context 'when logged in as group developer' do + context 'when logged in as group owner' do before do - sign_in(developer) - visit path + sign_in(owner) end it 'sidebar menu is open' do + visit path + sidebar = find('.nav-sidebar') expect(sidebar).to have_link _('Dependency Proxy') end it 'toggles defaults to enabled' do - page.within('.js-dependency-proxy-toggle-area') do - expect(find('.js-project-feature-toggle-input', visible: false).value).to eq('true') - end + visit path + + expect(page).to have_css('[data-testid="proxy-url"]') end it 'shows the proxy URL' do - page.within('.edit_dependency_proxy_group_setting') do - expect(find('.js-dependency-proxy-url').value).to have_content('/dependency_proxy/containers') - end + visit path + + expect(find('input[data-testid="proxy-url"]').value).to have_content('/dependency_proxy/containers') end it 'hides the proxy URL when feature is disabled' do - page.within('.edit_dependency_proxy_group_setting') do - find('.js-project-feature-toggle').click - end + visit settings_path + wait_for_requests + + click_button 'Enable Proxy' + + expect(page).to have_button 'Enable Proxy', class: '!is-checked' + + visit path - expect(page).not_to have_css('.js-dependency-proxy-url') - expect(find('.js-project-feature-toggle-input', visible: false).value).to eq('false') + expect(page).not_to have_css('input[data-testid="proxy-url"]') end end @@ -68,18 +73,17 @@ RSpec.describe 'Group Dependency Proxy' do end it 'does not show the feature toggle but shows the proxy URL' do - expect(page).not_to have_css('.js-dependency-proxy-toggle-area') - expect(find('.js-dependency-proxy-url').value).to have_content('/dependency_proxy/containers') + expect(find('input[data-testid="proxy-url"]').value).to have_content('/dependency_proxy/containers') end end end context 'feature is not avaible' do before do - sign_in(developer) + sign_in(owner) end - context 'feature flag is disabled' do + context 'feature flag is disabled', :js do before do stub_feature_flags(dependency_proxy_for_private_groups: false) end @@ -90,7 +94,7 @@ RSpec.describe 'Group Dependency Proxy' do it 'informs user that feature is only available for public groups' do visit path - expect(page).to have_content('Dependency proxy feature is limited to public groups for now.') + expect(page).to have_content('Dependency Proxy feature is limited to public groups for now.') end end end diff --git a/spec/features/groups/import_export/connect_instance_spec.rb b/spec/features/groups/import_export/connect_instance_spec.rb index cf893e444c4..552b599a3f3 100644 --- a/spec/features/groups/import_export/connect_instance_spec.rb +++ b/spec/features/groups/import_export/connect_instance_spec.rb @@ -19,34 +19,12 @@ RSpec.describe 'Import/Export - Connect to another instance', :js do end context 'when the user provides valid credentials' do + source_url = 'https://gitlab.com' + + include_context 'bulk imports requests context', source_url + it 'successfully connects to remote instance' do - source_url = 'https://gitlab.com' pat = 'demo-pat' - stub_path = 'stub-group' - total = 37 - - stub_request(:get, "%{url}/api/v4/groups?page=1&per_page=20&top_level_only=true&min_access_level=50&search=" % { url: source_url }).to_return( - body: [{ - id: 2595438, - web_url: 'https://gitlab.com/groups/auto-breakfast', - name: 'Stub', - path: stub_path, - full_name: 'Stub', - full_path: stub_path - }].to_json, - headers: { - 'Content-Type' => 'application/json', - 'X-Next-Page' => 2, - 'X-Page' => 1, - 'X-Per-Page' => 20, - 'X-Total' => total, - 'X-Total-Pages' => 2 - } - ) - - allow_next_instance_of(BulkImports::Clients::HTTP) do |client| - allow(client).to receive(:validate_instance_version!).and_return(true) - end expect(page).to have_content 'Import groups from another instance of GitLab' expect(page).to have_content 'Not all related objects are migrated' @@ -56,8 +34,8 @@ RSpec.describe 'Import/Export - Connect to another instance', :js do click_on 'Connect instance' - expect(page).to have_content 'Showing 1-1 of %{total} groups from %{url}' % { url: source_url, total: total } - expect(page).to have_content stub_path + expect(page).to have_content 'Showing 1-1 of 42 groups from %{url}' % { url: source_url } + expect(page).to have_content 'stub-group' visit '/' diff --git a/spec/features/groups/import_export/migration_history_spec.rb b/spec/features/groups/import_export/migration_history_spec.rb new file mode 100644 index 00000000000..243bdcc13a9 --- /dev/null +++ b/spec/features/groups/import_export/migration_history_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Import/Export - GitLab migration history', :js do + let_it_be(:user) { create(:user) } + + let_it_be(:user_import_1) { create(:bulk_import, user: user) } + let_it_be(:finished_entity_1) { create(:bulk_import_entity, :finished, bulk_import: user_import_1) } + + let_it_be(:user_import_2) { create(:bulk_import, user: user) } + let_it_be(:failed_entity_2) { create(:bulk_import_entity, :failed, bulk_import: user_import_2) } + + before do + gitlab_sign_in(user) + + visit new_group_path + + click_link 'Import group' + end + + it 'successfully displays import history' do + click_link 'History' + + wait_for_requests + + expect(page).to have_content 'Group import history' + expect(page.find('tbody')).to have_css('tr', count: 2) + end +end diff --git a/spec/features/groups/members/manage_groups_spec.rb b/spec/features/groups/members/manage_groups_spec.rb index 2dfcd941b4f..d822a5ea871 100644 --- a/spec/features/groups/members/manage_groups_spec.rb +++ b/spec/features/groups/members/manage_groups_spec.rb @@ -63,6 +63,7 @@ RSpec.describe 'Groups > Members > Manage groups', :js do context 'when group link exists' do let_it_be(:shared_with_group) { create(:group) } let_it_be(:shared_group) { create(:group) } + let_it_be(:expiration_date) { 5.days.from_now.to_date } let(:additional_link_attrs) { {} } @@ -115,29 +116,29 @@ RSpec.describe 'Groups > Members > Manage groups', :js do click_groups_tab page.within first_row do - fill_in 'Expiration date', with: 5.days.from_now.to_date + fill_in 'Expiration date', with: expiration_date find_field('Expiration date').native.send_keys :enter wait_for_requests - expect(page).to have_content(/in \d days/) + expect(page).to have_field('Expiration date', with: expiration_date) end end context 'when expiry date is set' do - let(:additional_link_attrs) { { expires_at: 5.days.from_now.to_date } } + let(:additional_link_attrs) { { expires_at: expiration_date } } it 'clears expiry date' do click_groups_tab page.within first_row do - expect(page).to have_content(/in \d days/) + expect(page).to have_field('Expiration date', with: expiration_date) find('[data-testid="clear-button"]').click wait_for_requests - expect(page).to have_content('No expiration set') + expect(page).to have_field('Expiration date', with: '') end end end diff --git a/spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb b/spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb index ddf3c6d8f9b..86185b8dd32 100644 --- a/spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb +++ b/spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb @@ -8,6 +8,7 @@ RSpec.describe 'Groups > Members > Owner adds member with expiration date', :js let_it_be(:user1) { create(:user, name: 'John Doe') } let_it_be(:group) { create(:group) } + let_it_be(:expiration_date) { 5.days.from_now.to_date } let(:new_member) { create(:user, name: 'Mary Jane') } @@ -19,10 +20,10 @@ RSpec.describe 'Groups > Members > Owner adds member with expiration date', :js it 'expiration date is displayed in the members list' do visit group_group_members_path(group) - invite_member(new_member.name, role: 'Guest', expires_at: 5.days.from_now.to_date) + invite_member(new_member.name, role: 'Guest', expires_at: expiration_date) page.within second_row do - expect(page).to have_content(/in \d days/) + expect(page).to have_field('Expiration date', with: expiration_date) end end @@ -31,27 +32,27 @@ RSpec.describe 'Groups > Members > Owner adds member with expiration date', :js visit group_group_members_path(group) page.within second_row do - fill_in 'Expiration date', with: 5.days.from_now.to_date + fill_in 'Expiration date', with: expiration_date find_field('Expiration date').native.send_keys :enter wait_for_requests - expect(page).to have_content(/in \d days/) + expect(page).to have_field('Expiration date', with: expiration_date) end end it 'clears expiration date' do - create(:group_member, :developer, user: new_member, group: group, expires_at: 5.days.from_now.to_date) + create(:group_member, :developer, user: new_member, group: group, expires_at: expiration_date) visit group_group_members_path(group) page.within second_row do - expect(page).to have_content(/in \d days/) + expect(page).to have_field('Expiration date', with: expiration_date) find('[data-testid="clear-button"]').click wait_for_requests - expect(page).to have_content('No expiration set') + expect(page).to have_field('Expiration date', with: '') end end end diff --git a/spec/features/groups/milestone_spec.rb b/spec/features/groups/milestone_spec.rb index c51ee250331..4edf27e8fa4 100644 --- a/spec/features/groups/milestone_spec.rb +++ b/spec/features/groups/milestone_spec.rb @@ -98,9 +98,11 @@ RSpec.describe 'Group milestones' do end it 'counts milestones correctly' do - expect(find('.top-area .active .badge').text).to eq("3") - expect(find('.top-area .closed .badge').text).to eq("3") - expect(find('.top-area .all .badge').text).to eq("6") + page.within '[data-testid="milestones-filter"]' do + expect(page).to have_content('Open 3') + expect(page).to have_content('Closed 3') + expect(page).to have_content('All 6') + end end it 'lists group and project milestones' do diff --git a/spec/features/groups/packages_spec.rb b/spec/features/groups/packages_spec.rb index 3c2ade6b274..0dfc7180187 100644 --- a/spec/features/groups/packages_spec.rb +++ b/spec/features/groups/packages_spec.rb @@ -28,6 +28,10 @@ RSpec.describe 'Group Packages' do context 'when feature is available', :js do before do + # we are simply setting the featrure flag to false because the new UI has nothing to test yet + # when the refactor is complete or almost complete we will turn on the feature tests + # see https://gitlab.com/gitlab-org/gitlab/-/issues/330846 for status of this work + stub_feature_flags(package_list_apollo: false) visit_group_packages end diff --git a/spec/features/issues/related_issues_spec.rb b/spec/features/issues/related_issues_spec.rb index 837859bbe26..a95229d4f1b 100644 --- a/spec/features/issues/related_issues_spec.rb +++ b/spec/features/issues/related_issues_spec.rb @@ -41,13 +41,13 @@ RSpec.describe 'Related issues', :js do visit project_issue_path(project, issue) expect(page).to have_css('.related-issues-block') - expect(page).not_to have_selector('.js-issue-count-badge-add-button') + expect(page).not_to have_button 'Add a related issue' end end context 'when logged in but not a member' do before do - gitlab_sign_in(user) + sign_in(user) end it 'shows widget when internal project' do @@ -57,7 +57,7 @@ RSpec.describe 'Related issues', :js do visit project_issue_path(project, issue) expect(page).to have_css('.related-issues-block') - expect(page).not_to have_selector('.js-issue-count-badge-add-button') + expect(page).not_to have_button 'Add a related issue' end it 'does not show widget when private project' do @@ -76,7 +76,7 @@ RSpec.describe 'Related issues', :js do visit project_issue_path(project, issue) expect(page).to have_css('.related-issues-block') - expect(page).not_to have_selector('.js-issue-count-badge-add-button') + expect(page).not_to have_button 'Add a related issue' end it 'shows widget on their own public issue' do @@ -86,13 +86,13 @@ RSpec.describe 'Related issues', :js do visit project_issue_path(project, issue) expect(page).to have_css('.related-issues-block') - expect(page).not_to have_selector('.js-issue-count-badge-add-button') + expect(page).not_to have_button 'Add a related issue' end end context 'when logged in and a guest' do before do - gitlab_sign_in(user) + sign_in(user) end it 'shows widget when internal project' do @@ -103,7 +103,7 @@ RSpec.describe 'Related issues', :js do visit project_issue_path(project, issue) expect(page).to have_css('.related-issues-block') - expect(page).not_to have_selector('.js-issue-count-badge-add-button') + expect(page).not_to have_button 'Add a related issue' end it 'shows widget when private project' do @@ -114,7 +114,7 @@ RSpec.describe 'Related issues', :js do visit project_issue_path(project, issue) expect(page).to have_css('.related-issues-block') - expect(page).not_to have_selector('.js-issue-count-badge-add-button') + expect(page).not_to have_button 'Add a related issue' end it 'shows widget when public project' do @@ -125,13 +125,13 @@ RSpec.describe 'Related issues', :js do visit project_issue_path(project, issue) expect(page).to have_css('.related-issues-block') - expect(page).not_to have_selector('.js-issue-count-badge-add-button') + expect(page).not_to have_button 'Add a related issue' end end context 'when logged in and a reporter' do before do - gitlab_sign_in(user) + sign_in(user) end it 'shows widget when internal project' do @@ -142,7 +142,7 @@ RSpec.describe 'Related issues', :js do visit project_issue_path(project, issue) expect(page).to have_css('.related-issues-block') - expect(page).to have_selector('.js-issue-count-badge-add-button') + expect(page).to have_button 'Add a related issue' end it 'shows widget when private project' do @@ -153,7 +153,7 @@ RSpec.describe 'Related issues', :js do visit project_issue_path(project, issue) expect(page).to have_css('.related-issues-block') - expect(page).to have_selector('.js-issue-count-badge-add-button') + expect(page).to have_button 'Add a related issue' end it 'shows widget when public project' do @@ -164,7 +164,7 @@ RSpec.describe 'Related issues', :js do visit project_issue_path(project, issue) expect(page).to have_css('.related-issues-block') - expect(page).to have_selector('.js-issue-count-badge-add-button') + expect(page).to have_button 'Add a related issue' end it 'shows widget on their own public issue' do @@ -175,7 +175,7 @@ RSpec.describe 'Related issues', :js do visit project_issue_path(project, issue) expect(page).to have_css('.related-issues-block') - expect(page).to have_selector('.js-issue-count-badge-add-button') + expect(page).to have_button 'Add a related issue' end end end @@ -186,7 +186,7 @@ RSpec.describe 'Related issues', :js do before do project.add_guest(user) - gitlab_sign_in(user) + sign_in(user) end context 'visiting some issue someone else created' do @@ -216,7 +216,7 @@ RSpec.describe 'Related issues', :js do before do project.add_maintainer(user) project_b.add_maintainer(user) - gitlab_sign_in(user) + sign_in(user) end context 'without existing related issues' do @@ -230,9 +230,9 @@ RSpec.describe 'Related issues', :js do end it 'add related issue' do - find('.js-issue-count-badge-add-button').click - find('.js-add-issuable-form-input').set "#{issue_b.to_reference(project)} " - find('.js-add-issuable-form-add-button').click + click_button 'Add a related issue' + fill_in 'Paste issue link', with: "#{issue_b.to_reference(project)} " + click_button 'Add' wait_for_requests @@ -247,9 +247,9 @@ RSpec.describe 'Related issues', :js do end it 'add cross-project related issue' do - find('.js-issue-count-badge-add-button').click - find('.js-add-issuable-form-input').set "#{issue_project_b_a.to_reference(project)} " - find('.js-add-issuable-form-add-button').click + click_button 'Add a related issue' + fill_in 'Paste issue link', with: "#{issue_project_b_a.to_reference(project)} " + click_button 'Add' wait_for_requests @@ -261,9 +261,9 @@ RSpec.describe 'Related issues', :js do end it 'pressing enter should submit the form' do - find('.js-issue-count-badge-add-button').click - find('.js-add-issuable-form-input').set "#{issue_project_b_a.to_reference(project)} " - find('.js-add-issuable-form-input').native.send_key(:enter) + click_button 'Add a related issue' + fill_in 'Paste issue link', with: "#{issue_project_b_a.to_reference(project)} " + find_field('Paste issue link').native.send_key(:enter) wait_for_requests @@ -275,10 +275,10 @@ RSpec.describe 'Related issues', :js do end it 'disallows duplicate entries' do - find('.js-issue-count-badge-add-button').click - find('.js-add-issuable-form-input').set 'duplicate duplicate duplicate' + click_button 'Add a related issue' + fill_in 'Paste issue link', with: 'duplicate duplicate duplicate' - items = all('.js-add-issuable-form-token-list-item') + items = all('.issue-token') expect(items.count).to eq(1) expect(items[0].text).to eq('duplicate') @@ -288,29 +288,35 @@ RSpec.describe 'Related issues', :js do it 'allows us to remove pending issues' do # Tests against https://gitlab.com/gitlab-org/gitlab/issues/11625 - find('.js-issue-count-badge-add-button').click - find('.js-add-issuable-form-input').set 'issue1 issue2 issue3 ' + click_button 'Add a related issue' + fill_in 'Paste issue link', with: 'issue1 issue2 issue3 ' - items = all('.js-add-issuable-form-token-list-item') + items = all('.issue-token') expect(items.count).to eq(3) expect(items[0].text).to eq('issue1') expect(items[1].text).to eq('issue2') expect(items[2].text).to eq('issue3') # Remove pending issues left to right to make sure none get stuck - items[0].find('.js-issue-token-remove-button').click - items = all('.js-add-issuable-form-token-list-item') + within items[0] do + click_button 'Remove' + end + items = all('.issue-token') expect(items.count).to eq(2) expect(items[0].text).to eq('issue2') expect(items[1].text).to eq('issue3') - items[0].find('.js-issue-token-remove-button').click - items = all('.js-add-issuable-form-token-list-item') + within items[0] do + click_button 'Remove' + end + items = all('.issue-token') expect(items.count).to eq(1) expect(items[0].text).to eq('issue3') - items[0].find('.js-issue-token-remove-button').click - items = all('.js-add-issuable-form-token-list-item') + within items[0] do + click_button 'Remove' + end + items = all('.issue-token') expect(items.count).to eq(0) end end @@ -351,9 +357,9 @@ RSpec.describe 'Related issues', :js do end it 'add related issue' do - find('.js-issue-count-badge-add-button').click - find('.js-add-issuable-form-input').set "##{issue_d.iid} " - find('.js-add-issuable-form-add-button').click + click_button 'Add a related issue' + fill_in 'Paste issue link', with: "##{issue_d.iid} " + click_button 'Add' wait_for_requests @@ -367,9 +373,9 @@ RSpec.describe 'Related issues', :js do end it 'add invalid related issue' do - find('.js-issue-count-badge-add-button').click - find('.js-add-issuable-form-input').set "#9999999 " - find('.js-add-issuable-form-add-button').click + click_button 'Add a related issue' + fill_in 'Paste issue link', with: '#9999999 ' + click_button 'Add' wait_for_requests @@ -382,9 +388,9 @@ RSpec.describe 'Related issues', :js do end it 'add unauthorized related issue' do - find('.js-issue-count-badge-add-button').click - find('.js-add-issuable-form-input').set "#{issue_project_unauthorized_a.to_reference(project)} " - find('.js-add-issuable-form-add-button').click + click_button 'Add a related issue' + fill_in 'Paste issue link', with: "#{issue_project_unauthorized_a.to_reference(project)} " + click_button 'Add' wait_for_requests diff --git a/spec/features/markdown/copy_as_gfm_spec.rb b/spec/features/markdown/copy_as_gfm_spec.rb index c700f878df6..d3aaf339421 100644 --- a/spec/features/markdown/copy_as_gfm_spec.rb +++ b/spec/features/markdown/copy_as_gfm_spec.rb @@ -201,6 +201,15 @@ RSpec.describe 'Copy as GFM', :js do GFM ) + aggregate_failures('CustomEmojiFilter') do + gfm = ':custom_emoji:' + + html = '<img class="emoji" src="custom_emoji.svg" title=":custom_emoji:" height="20" width="20">' + + output_gfm = html_to_gfm(html) + expect(output_gfm.strip).to eq(gfm.strip) + end + aggregate_failures('MathFilter: math as transformed from HTML to KaTeX') do gfm = '$`c = \pm\sqrt{a^2 + b^2}`$' diff --git a/spec/features/markdown/markdown_spec.rb b/spec/features/markdown/markdown_spec.rb index 3208ad82c03..9eff02a8c1b 100644 --- a/spec/features/markdown/markdown_spec.rb +++ b/spec/features/markdown/markdown_spec.rb @@ -133,8 +133,9 @@ RSpec.describe 'GitLab Markdown', :aggregate_failures do expect(doc.at_css('td:contains("Baz")')['align']).to eq 'left' end + # note that 2 are from the hardcoded <sup>, and 2 from footnotes aggregate_failures 'permits superscript elements' do - expect(doc).to have_selector('sup', count: 2) + expect(doc).to have_selector('sup', count: 4) end aggregate_failures 'permits subscript elements' do @@ -148,6 +149,11 @@ RSpec.describe 'GitLab Markdown', :aggregate_failures do aggregate_failures "removes `href` from `a` elements if it's fishy" do expect(doc).not_to have_selector('a[href*="javascript"]') end + + aggregate_failures 'permits footnotes' do + expect(doc).to have_selector('section.footnotes ol li p:contains("Footnote 1")') + expect(doc).to have_selector('section.footnotes ol li p:contains("Footnote with w")') + end end describe 'Escaping' do diff --git a/spec/features/merge_request/user_merges_immediately_spec.rb b/spec/features/merge_request/user_merges_immediately_spec.rb index bca6e6ceba5..3a05f35a671 100644 --- a/spec/features/merge_request/user_merges_immediately_spec.rb +++ b/spec/features/merge_request/user_merges_immediately_spec.rb @@ -36,7 +36,7 @@ RSpec.describe 'Merge requests > User merges immediately', :js do Sidekiq::Testing.fake! do click_button 'Merge immediately' - expect(find('.accept-merge-request.btn-confirm')).to have_content('Merge in progress') + expect(find('.media-body h4')).to have_content('Merging!') wait_for_requests end diff --git a/spec/features/merge_request/user_resolves_conflicts_spec.rb b/spec/features/merge_request/user_resolves_conflicts_spec.rb index 03ab42aaccd..982e75760d7 100644 --- a/spec/features/merge_request/user_resolves_conflicts_spec.rb +++ b/spec/features/merge_request/user_resolves_conflicts_spec.rb @@ -9,7 +9,7 @@ RSpec.describe 'Merge request > User resolves conflicts', :js do let(:user) { project.creator } def create_merge_request(source_branch) - create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start', source_project: project, merge_status: :unchecked) do |mr| + create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start', source_project: project, merge_status: :unchecked, reviewers: [user]) do |mr| mr.mark_as_unmergeable end end @@ -178,6 +178,23 @@ RSpec.describe 'Merge request > User resolves conflicts', :js do end end + context 'sidebar' do + let(:merge_request) { create_merge_request('conflict-resolvable') } + + before do + project.add_developer(user) + sign_in(user) + + visit conflicts_project_merge_request_path(project, merge_request) + end + + it 'displays reviewers' do + page.within '.issuable-sidebar' do + expect(page).to have_selector('[data-testid="reviewer"]', count: 1) + end + end + end + unresolvable_conflicts = { 'conflict-too-large' => 'when the conflicts contain a large file', 'conflict-binary-file' => 'when the conflicts contain a binary file', diff --git a/spec/features/merge_request/user_sees_deployment_widget_spec.rb b/spec/features/merge_request/user_sees_deployment_widget_spec.rb index 1e547d504ef..873cc0a89c6 100644 --- a/spec/features/merge_request/user_sees_deployment_widget_spec.rb +++ b/spec/features/merge_request/user_sees_deployment_widget_spec.rb @@ -13,6 +13,8 @@ RSpec.describe 'Merge request > User sees deployment widget', :js do let(:sha) { project.commit(ref).id } let(:pipeline) { create(:ci_pipeline, sha: sha, project: project, ref: ref) } let!(:manual) { } + let(:build) { create(:ci_build, :with_deployment, environment: environment.name, pipeline: pipeline) } + let!(:deployment) { build.deployment } before do merge_request.update!(merge_commit_sha: sha) @@ -21,8 +23,9 @@ RSpec.describe 'Merge request > User sees deployment widget', :js do end context 'when deployment succeeded' do - let(:build) { create(:ci_build, :success, pipeline: pipeline) } - let!(:deployment) { create(:deployment, :succeed, environment: environment, sha: sha, ref: ref, deployable: build) } + before do + build.success! + end it 'displays that the environment is deployed' do visit project_merge_request_path(project, merge_request) @@ -34,9 +37,8 @@ RSpec.describe 'Merge request > User sees deployment widget', :js do context 'when a user created a new merge request with the same SHA' do let(:pipeline2) { create(:ci_pipeline, sha: sha, project: project, ref: 'video') } - let(:build2) { create(:ci_build, :success, pipeline: pipeline2) } let(:environment2) { create(:environment, project: project) } - let!(:deployment2) { create(:deployment, environment: environment2, sha: sha, ref: 'video', deployable: build2) } + let!(:build2) { create(:ci_build, :with_deployment, :success, environment: environment2.name, pipeline: pipeline2) } it 'displays one environment which is related to the pipeline' do visit project_merge_request_path(project, merge_request) @@ -50,8 +52,9 @@ RSpec.describe 'Merge request > User sees deployment widget', :js do end context 'when deployment failed' do - let(:build) { create(:ci_build, :failed, pipeline: pipeline) } - let!(:deployment) { create(:deployment, :failed, environment: environment, sha: sha, ref: ref, deployable: build) } + before do + build.drop! + end it 'displays that the deployment failed' do visit project_merge_request_path(project, merge_request) @@ -63,8 +66,9 @@ RSpec.describe 'Merge request > User sees deployment widget', :js do end context 'when deployment running' do - let(:build) { create(:ci_build, :running, pipeline: pipeline) } - let!(:deployment) { create(:deployment, :running, environment: environment, sha: sha, ref: ref, deployable: build) } + before do + build.run! + end it 'displays that the running deployment' do visit project_merge_request_path(project, merge_request) @@ -76,8 +80,8 @@ RSpec.describe 'Merge request > User sees deployment widget', :js do end context 'when deployment will happen' do - let(:build) { create(:ci_build, :created, pipeline: pipeline) } - let!(:deployment) { create(:deployment, environment: environment, sha: sha, ref: ref, deployable: build) } + let(:build) { create(:ci_build, :with_deployment, environment: environment.name, pipeline: pipeline) } + let!(:deployment) { build.deployment } it 'displays that the environment name' do visit project_merge_request_path(project, merge_request) @@ -89,8 +93,9 @@ RSpec.describe 'Merge request > User sees deployment widget', :js do end context 'when deployment was cancelled' do - let(:build) { create(:ci_build, :canceled, pipeline: pipeline) } - let!(:deployment) { create(:deployment, :canceled, environment: environment, sha: sha, ref: ref, deployable: build) } + before do + build.cancel! + end it 'displays that the environment name' do visit project_merge_request_path(project, merge_request) @@ -102,11 +107,10 @@ RSpec.describe 'Merge request > User sees deployment widget', :js do end context 'with stop action' do - let(:build) { create(:ci_build, :success, pipeline: pipeline) } - let!(:deployment) { create(:deployment, :succeed, environment: environment, sha: sha, ref: ref, deployable: build) } let(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') } before do + build.success! deployment.update!(on_stop: manual.name) visit project_merge_request_path(project, merge_request) wait_for_requests 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 2f7758143a1..f74b097ab3e 100644 --- a/spec/features/merge_request/user_sees_merge_widget_spec.rb +++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb @@ -45,18 +45,12 @@ RSpec.describe 'Merge request > User sees merge widget', :js do let!(:environment) { create(:environment, project: project) } let(:sha) { project.commit(merge_request.source_branch).sha } let(:pipeline) { create(:ci_pipeline, status: 'success', sha: sha, project: project, ref: merge_request.source_branch) } - let(:build) { create(:ci_build, :success, pipeline: pipeline) } - - let!(:deployment) do - create(:deployment, :succeed, - environment: environment, - ref: merge_request.source_branch, - deployable: build, - sha: sha) - end + let!(:build) { create(:ci_build, :with_deployment, :success, environment: environment.name, pipeline: pipeline) } + let!(:deployment) { build.deployment } before do merge_request.update!(head_pipeline: pipeline) + deployment.update!(status: :success) visit project_merge_request_path(project, merge_request) end diff --git a/spec/features/merge_request/user_sees_suggest_pipeline_spec.rb b/spec/features/merge_request/user_sees_suggest_pipeline_spec.rb index 4bb6c3265a4..3893a9cdf28 100644 --- a/spec/features/merge_request/user_sees_suggest_pipeline_spec.rb +++ b/spec/features/merge_request/user_sees_suggest_pipeline_spec.rb @@ -6,9 +6,10 @@ RSpec.describe 'Merge request > User sees suggest pipeline', :js do let(:merge_request) { create(:merge_request) } let(:project) { merge_request.source_project } let(:user) { project.creator } + let(:suggest_pipeline_enabled) { true } before do - stub_application_setting(auto_devops_enabled: false) + stub_application_setting(suggest_pipeline_enabled: suggest_pipeline_enabled, auto_devops_enabled: false) project.add_maintainer(user) sign_in(user) visit project_merge_request_path(project, merge_request) @@ -66,4 +67,12 @@ RSpec.describe 'Merge request > User sees suggest pipeline', :js do # nudge 4 expect(page).to have_content("That's it, well done!") end + + context 'when feature setting is disabled' do + let(:suggest_pipeline_enabled) { false } + + it 'does not show the suggest pipeline widget' do + expect(page).not_to have_content('Are you adding technical debt or code vulnerabilities?') + end + end end 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 275a87ca391..d2bde320c54 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 @@ -64,7 +64,7 @@ RSpec.describe 'Merge request > User selects branches for new MR', :js do click_button "Check out branch" - expect(page).to have_content 'git checkout -b "orphaned-branch" "origin/orphaned-branch"' + expect(page).to have_content 'git checkout -b \'orphaned-branch\' \'origin/orphaned-branch\'' end it 'allows filtering multiple dropdowns' do 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 dbc88d0cce2..690a292937a 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 @@ -159,7 +159,12 @@ RSpec.describe 'User comments on a diff', :js do wait_for_requests expect(page).to have_content('Remove from batch') - expect(page).to have_content("Apply suggestions #{index + 1}") + + if index < 1 + expect(page).to have_content("Apply suggestion") + else + expect(page).to have_content("Apply #{index + 1} suggestions") + end end end @@ -167,13 +172,12 @@ RSpec.describe 'User comments on a diff', :js do click_button('Remove from batch') wait_for_requests - expect(page).to have_content('Apply suggestion') expect(page).to have_content('Add suggestion to batch') end page.within("[id='#{files[1][:hash]}']") do expect(page).to have_content('Remove from batch') - expect(page).to have_content('Apply suggestions 1') + expect(page).to have_content('Apply suggestion') end end diff --git a/spec/features/profiles/password_spec.rb b/spec/features/profiles/password_spec.rb index 893dd2c76e0..7059697354d 100644 --- a/spec/features/profiles/password_spec.rb +++ b/spec/features/profiles/password_spec.rb @@ -89,7 +89,7 @@ RSpec.describe 'Profile > Password' do shared_examples 'user enters an incorrect current password' do subject do page.within '.update-password' do - fill_in 'user_current_password', with: user_current_password + fill_in 'user_password', with: user_current_password fill_passwords(new_password, new_password) end end @@ -131,7 +131,7 @@ RSpec.describe 'Profile > Password' do end context 'when current password is incorrect' do - let(:user_current_password) {'invalid' } + let(:user_current_password) { 'invalid' } it_behaves_like 'user enters an incorrect current password' end @@ -139,7 +139,7 @@ RSpec.describe 'Profile > Password' do context 'when the password reset is successful' do subject do page.within '.update-password' do - fill_in "user_current_password", with: user.password + fill_in "user_password", with: user.password fill_passwords(new_password, new_password) end end @@ -169,8 +169,8 @@ RSpec.describe 'Profile > Password' do expect(current_path).to eq new_profile_password_path - fill_in :user_current_password, with: user.password - fill_in :user_password, with: '12345678' + fill_in :user_password, with: user.password + fill_in :user_new_password, with: '12345678' fill_in :user_password_confirmation, with: '12345678' click_button 'Set new password' diff --git a/spec/features/profiles/two_factor_auths_spec.rb b/spec/features/profiles/two_factor_auths_spec.rb index 7f3ce617846..3f5789e119a 100644 --- a/spec/features/profiles/two_factor_auths_spec.rb +++ b/spec/features/profiles/two_factor_auths_spec.rb @@ -29,7 +29,7 @@ RSpec.describe 'Two factor auths' do end context 'when user authenticates with an external service' do - let_it_be(:user) { create(:omniauth_user, password_automatically_set: true) } + let_it_be(:user) { create(:omniauth_user) } it 'does not require the current password to set up two factor authentication', :js do visit profile_two_factor_auth_path @@ -88,7 +88,7 @@ RSpec.describe 'Two factor auths' do end context 'when user authenticates with an external service' do - let_it_be(:user) { create(:omniauth_user, :two_factor, password_automatically_set: true) } + let_it_be(:user) { create(:omniauth_user, :two_factor) } it 'does not require the current_password to disable two-factor authentication', :js do visit profile_two_factor_auth_path diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb index af085b63155..026da5814e3 100644 --- a/spec/features/profiles/user_edit_profile_spec.rb +++ b/spec/features/profiles/user_edit_profile_spec.rb @@ -19,6 +19,17 @@ RSpec.describe 'User edit profile' do wait_for_requests if respond_to?(:wait_for_requests) end + def update_user_email + fill_in 'user_email', with: 'new-email@example.com' + click_button 'Update profile settings' + end + + def confirm_password(password) + fill_in 'password-confirmation', with: password + click_button 'Confirm password' + wait_for_requests if respond_to?(:wait_for_requests) + end + def visit_user visit user_path(user) wait_for_requests @@ -88,16 +99,42 @@ RSpec.describe 'User edit profile' do expect(page).to have_content('Website url is not a valid URL') end - describe 'when I change my email' do + describe 'when I change my email', :js do before do user.send_reset_password_instructions end + it 'will prompt to confirm my password' do + expect(user.reset_password_token?).to be true + + update_user_email + + expect(page).to have_selector('[data-testid="password-prompt-modal"]') + end + + context 'when prompted to confirm password' do + before do + update_user_email + end + + it 'with the correct password successfully updates' do + confirm_password(user.password) + + expect(page).to have_text("Profile was successfully updated") + end + + it 'with the incorrect password fails to update' do + confirm_password("Fake password") + + expect(page).to have_text("Invalid password") + end + end + it 'clears the reset password token' do expect(user.reset_password_token?).to be true - fill_in 'user_email', with: 'new-email@example.com' - submit_settings + update_user_email + confirm_password(user.password) user.reload expect(user.confirmation_token).not_to be_nil @@ -524,14 +561,11 @@ RSpec.describe 'User edit profile' do page.find("a", text: "Nuku'alofa").click - tz = page.find('.user-time-preferences #user_timezone', visible: false) - - expect(tz.value).to eq('Pacific/Tongatapu') + expect(page).to have_field(:user_timezone, with: 'Pacific/Tongatapu', type: :hidden) end - it 'timezone defaults to servers default' do - timezone_name = Time.zone.tzinfo.name - expect(page.find('.user-time-preferences #user_timezone', visible: false).value).to eq(timezone_name) + it 'timezone defaults to empty' do + expect(page).to have_field(:user_timezone, with: '', type: :hidden) end end end diff --git a/spec/features/projects/badges/coverage_spec.rb b/spec/features/projects/badges/coverage_spec.rb index 1760ec880bc..5c1bc1ad239 100644 --- a/spec/features/projects/badges/coverage_spec.rb +++ b/spec/features/projects/badges/coverage_spec.rb @@ -12,6 +12,120 @@ RSpec.describe 'test coverage badge' do sign_in(user) end + it 'user requests coverage badge image for pipeline with custom limits - 80% good' do + create_pipeline do |pipeline| + create_build(pipeline, coverage: 80, name: 'test:1') + end + + show_test_coverage_badge(min_good: 75, min_acceptable: 50, min_medium: 25) + + expect_coverage_badge_color(:good) + expect_coverage_badge('80.00%') + end + + it 'user requests coverage badge image for pipeline with custom limits - 74% - bad config' do + create_pipeline do |pipeline| + create_build(pipeline, coverage: 74, name: 'test:1') + end + # User sets a minimum good value that is lower than min acceptable and min medium, + # in which case we force the min acceptable value to be min good -1 and min medium value to be min acceptable -1 + show_test_coverage_badge(min_good: 75, min_acceptable: 76, min_medium: 77) + + expect_coverage_badge_color(:acceptable) + expect_coverage_badge('74.00%') + end + + it 'user requests coverage badge image for pipeline with custom limits - 73% - bad config' do + create_pipeline do |pipeline| + create_build(pipeline, coverage: 73, name: 'test:1') + end + # User sets a minimum good value that is lower than min acceptable and min medium, + # in which case we force the min acceptable value to be min good -1 and min medium value to be min acceptable -1 + show_test_coverage_badge(min_good: 75, min_acceptable: 76, min_medium: 77) + + expect_coverage_badge_color(:medium) + expect_coverage_badge('73.00%') + end + + it 'user requests coverage badge image for pipeline with custom limits - 72% - partial config - low' do + create_pipeline do |pipeline| + create_build(pipeline, coverage: 72, name: 'test:1') + end + # User only sets good to 75 and leaves the others on the default settings, + # in which case we force the min acceptable value to be min good -1 and min medium value to be min acceptable -1 + show_test_coverage_badge(min_good: 75) + + expect_coverage_badge_color(:low) + expect_coverage_badge('72.00%') + end + + it 'user requests coverage badge image for pipeline with custom limits - 72% - partial config - medium' do + create_pipeline do |pipeline| + create_build(pipeline, coverage: 72, name: 'test:1') + end + # User only sets good to 74 and leaves the others on the default settings, + # in which case we force the min acceptable value to be min good -1 and min medium value to be min acceptable -1 + show_test_coverage_badge(min_good: 74) + + expect_coverage_badge_color(:medium) + expect_coverage_badge('72.00%') + end + + it 'user requests coverage badge image for pipeline with custom limits - 72% - partial config - medium v2' do + create_pipeline do |pipeline| + create_build(pipeline, coverage: 72, name: 'test:1') + end + # User only sets medium to 72 and leaves the others on the defaults good as 95 and acceptable as 90 + show_test_coverage_badge(min_medium: 72) + + expect_coverage_badge_color(:medium) + expect_coverage_badge('72.00%') + end + + it 'user requests coverage badge image for pipeline with custom limits - 70% acceptable' do + create_pipeline do |pipeline| + create_build(pipeline, coverage: 70, name: 'test:1') + end + + show_test_coverage_badge(min_good: 75, min_acceptable: 50, min_medium: 25) + + expect_coverage_badge_color(:acceptable) + expect_coverage_badge('70.00%') + end + + it 'user requests coverage badge image for pipeline with custom limits - 30% medium' do + create_pipeline do |pipeline| + create_build(pipeline, coverage: 30, name: 'test:1') + end + + show_test_coverage_badge(min_good: 75, min_acceptable: 50, min_medium: 25) + + expect_coverage_badge_color(:medium) + expect_coverage_badge('30.00%') + end + + it 'user requests coverage badge image for pipeline with custom limits - 20% low' do + create_pipeline do |pipeline| + create_build(pipeline, coverage: 20, name: 'test:1') + end + + show_test_coverage_badge(min_good: 75, min_acceptable: 50, min_medium: 25) + + expect_coverage_badge_color(:low) + expect_coverage_badge('20.00%') + end + + it 'user requests coverage badge image for pipeline with custom limits - nonsense values which use the defaults' do + create_pipeline do |pipeline| + create_build(pipeline, coverage: 92, name: 'test:1') + end + + show_test_coverage_badge(min_good: "nonsense", min_acceptable: "rubbish", min_medium: "NaN") + + expect_coverage_badge_color(:acceptable) + expect_coverage_badge('92.00%') + end + it 'user requests coverage badge image for pipeline' do create_pipeline do |pipeline| create_build(pipeline, coverage: 100, name: 'test:1') @@ -20,6 +134,7 @@ RSpec.describe 'test coverage badge' do show_test_coverage_badge + expect_coverage_badge_color(:good) expect_coverage_badge('95.00%') end @@ -32,6 +147,7 @@ RSpec.describe 'test coverage badge' do show_test_coverage_badge(job: 'coverage') + expect_coverage_badge_color(:medium) expect_coverage_badge('85.00%') end @@ -73,8 +189,9 @@ RSpec.describe 'test coverage badge' do create(:ci_build, :success, opts) end - def show_test_coverage_badge(job: nil) - visit coverage_project_badges_path(project, ref: :master, job: job, format: :svg) + def show_test_coverage_badge(job: nil, min_good: nil, min_acceptable: nil, min_medium: nil) + visit coverage_project_badges_path(project, ref: :master, job: job, min_good: min_good, + min_acceptable: min_acceptable, min_medium: min_medium, format: :svg) end def expect_coverage_badge(coverage) @@ -82,4 +199,12 @@ RSpec.describe 'test coverage badge' do expect(page.response_headers['Content-Type']).to include('image/svg+xml') expect(svg.at(%Q{text:contains("#{coverage}")})).to be_truthy end + + def expect_coverage_badge_color(color) + svg = Nokogiri::HTML(page.body) + expect(page.response_headers['Content-Type']).to include('image/svg+xml') + badge_color = svg.xpath("//path[starts-with(@d, 'M62')]")[0].attributes['fill'].to_s + expected_badge_color = Gitlab::Ci::Badge::Coverage::Template::STATUS_COLOR[color] + expect(badge_color).to eq(expected_badge_color) + end end diff --git a/spec/features/projects/badges/pipeline_badge_spec.rb b/spec/features/projects/badges/pipeline_badge_spec.rb index 9d8f9872a1a..e3a01ab6fa2 100644 --- a/spec/features/projects/badges/pipeline_badge_spec.rb +++ b/spec/features/projects/badges/pipeline_badge_spec.rb @@ -68,7 +68,7 @@ RSpec.describe 'Pipeline Badge' do visit pipeline_project_badges_path(project, ref: ref, format: :svg) expect(page.status_code).to eq(200) - expect(page.response_headers['Cache-Control']).to eq('no-store') + expect(page.response_headers['Cache-Control']).to eq('private, no-store') end end diff --git a/spec/features/projects/ci/lint_spec.rb b/spec/features/projects/ci/lint_spec.rb index 0d9ea6331a7..7f10c6afcd5 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, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/297782' do +RSpec.describe 'CI Lint', :js do include Spec::Support::Helpers::Features::SourceEditorSpecHelpers let(:project) { create(:project, :repository) } diff --git a/spec/features/projects/container_registry_spec.rb b/spec/features/projects/container_registry_spec.rb index 40d0260eafd..eec50c3a66a 100644 --- a/spec/features/projects/container_registry_spec.rb +++ b/spec/features/projects/container_registry_spec.rb @@ -20,6 +20,7 @@ RSpec.describe 'Container Registry', :js do sign_in(user) project.add_developer(user) stub_container_registry_config(enabled: true) + stub_container_registry_info stub_container_registry_tags(repository: :any, tags: []) end @@ -96,6 +97,7 @@ RSpec.describe 'Container Registry', :js do expect(service).to receive(:execute).with(container_repository) { { status: :success } } expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['1']) { service } + first('[data-testid="additional-actions"]').click first('[data-testid="single-delete-button"]').click expect(find('.modal .modal-title')).to have_content _('Remove tag') find('.modal .modal-footer .btn-danger').click @@ -121,6 +123,16 @@ RSpec.describe 'Container Registry', :js do expect(page).to have_content('Digest: N/A') end end + + [ContainerRegistry::Path::InvalidRegistryPathError, Faraday::Error].each do |error_class| + context "when there is a #{error_class}" do + before do + expect(::ContainerRegistry::Client).to receive(:registry_info).and_raise(error_class, nil, nil) + end + + it_behaves_like 'handling feature network errors with the container registry' + end + end end describe 'image repo details when image has no name' do diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb index 9413fae02e0..34e2ca7c8a7 100644 --- a/spec/features/projects/environments/environments_spec.rb +++ b/spec/features/projects/environments/environments_spec.rb @@ -226,6 +226,7 @@ RSpec.describe 'Environments page', :js do end it 'does not show terminal button' do + expect(page).not_to have_button(_('More actions')) expect(page).not_to have_terminal_button end @@ -273,6 +274,7 @@ RSpec.describe 'Environments page', :js do let(:role) { :maintainer } it 'shows the terminal button' do + click_button(_('More actions')) expect(page).to have_terminal_button end end @@ -281,6 +283,7 @@ RSpec.describe 'Environments page', :js do let(:role) { :developer } it 'does not show terminal button' do + expect(page).not_to have_button(_('More actions')) expect(page).not_to have_terminal_button end end @@ -515,7 +518,7 @@ RSpec.describe 'Environments page', :js do end def have_terminal_button - have_link(nil, href: terminal_project_environment_path(project, environment)) + have_link(_('Terminal'), href: terminal_project_environment_path(project, environment)) end def visit_environments(project, **opts) diff --git a/spec/features/projects/files/user_creates_directory_spec.rb b/spec/features/projects/files/user_creates_directory_spec.rb index 46b93d738e1..5ad7641a5be 100644 --- a/spec/features/projects/files/user_creates_directory_spec.rb +++ b/spec/features/projects/files/user_creates_directory_spec.rb @@ -98,12 +98,14 @@ RSpec.describe 'Projects > Files > User creates a directory', :js do expect(page).to have_content(fork_message) find('.add-to-tree').click + wait_for_requests click_link('New directory') fill_in(:dir_name, with: 'new_directory') fill_in(:commit_message, with: 'New commit message', visible: true) click_button('Create directory') fork = user.fork_of(project2.reload) + wait_for_requests expect(current_path).to eq(project_new_merge_request_path(fork)) end diff --git a/spec/features/projects/files/user_uploads_files_spec.rb b/spec/features/projects/files/user_uploads_files_spec.rb index 54e816d3d13..cc621dfd9f8 100644 --- a/spec/features/projects/files/user_uploads_files_spec.rb +++ b/spec/features/projects/files/user_uploads_files_spec.rb @@ -19,13 +19,15 @@ RSpec.describe 'Projects > Files > User uploads files' do wait_for_requests end - include_examples 'it uploads and commits a new text file' + [true, false].each do |value| + include_examples 'it uploads and commits a new text file', drop: value - include_examples 'it uploads and commits a new image file' + include_examples 'it uploads and commits a new image file', drop: value - include_examples 'it uploads and commits a new pdf file' + include_examples 'it uploads and commits a new pdf file', drop: value - include_examples 'it uploads a file to a sub-directory' + include_examples 'it uploads a file to a sub-directory', drop: value + end end context 'when a user does not have write access' do @@ -35,6 +37,8 @@ RSpec.describe 'Projects > Files > User uploads files' do visit(project_tree_path(project2)) end - include_examples 'it uploads and commits a new file to a forked project' + [true, false].each do |value| + include_examples 'it uploads and commits a new file to a forked project', drop: value + end end end diff --git a/spec/features/projects/infrastructure_registry_spec.rb b/spec/features/projects/infrastructure_registry_spec.rb index 16dd96e6c02..ee35e02b5e8 100644 --- a/spec/features/projects/infrastructure_registry_spec.rb +++ b/spec/features/projects/infrastructure_registry_spec.rb @@ -45,10 +45,8 @@ RSpec.describe 'Infrastructure Registry' do expect(page).to have_css('.packages-app h1[data-testid="title"]', text: terraform_module.name) - page.within(%Q([name="#{terraform_module.name}"])) do - expect(page).to have_content('Provision instructions') - expect(page).to have_content('Registry setup') - end + expect(page).to have_content('Provision instructions') + expect(page).to have_content('Registry setup') end end diff --git a/spec/features/projects/jobs/user_browses_jobs_spec.rb b/spec/features/projects/jobs/user_browses_jobs_spec.rb index dbcd7b5caf5..8538b894869 100644 --- a/spec/features/projects/jobs/user_browses_jobs_spec.rb +++ b/spec/features/projects/jobs/user_browses_jobs_spec.rb @@ -2,36 +2,276 @@ require 'spec_helper' +def visit_jobs_page + visit(project_jobs_path(project)) + + wait_for_requests +end + RSpec.describe 'User browses jobs' do - let!(:build) { create(:ci_build, :coverage, pipeline: pipeline) } - let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') } - let(:project) { create(:project, :repository, namespace: user.namespace) } - let(:user) { create(:user) } + describe 'with jobs_table_vue feature flag turned off' do + let!(:build) { create(:ci_build, :coverage, pipeline: pipeline) } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') } + let(:project) { create(:project, :repository, namespace: user.namespace) } + let(:user) { create(:user) } - before do - stub_feature_flags(jobs_table_vue: false) - project.add_maintainer(user) - project.enable_ci - project.update_attribute(:build_coverage_regex, /Coverage (\d+)%/) + before do + stub_feature_flags(jobs_table_vue: false) + project.add_maintainer(user) + project.enable_ci + project.update_attribute(:build_coverage_regex, /Coverage (\d+)%/) - sign_in(user) + sign_in(user) - visit(project_jobs_path(project)) - end + visit(project_jobs_path(project)) + end - it 'shows the coverage' do - page.within('td.coverage') do - expect(page).to have_content('99.9%') + it 'shows the coverage' do + page.within('td.coverage') do + expect(page).to have_content('99.9%') + end + end + + context 'with a failed job' do + let!(:build) { create(:ci_build, :coverage, :failed, pipeline: pipeline) } + + it 'displays a tooltip with the failure reason' do + page.within('.ci-table') do + failed_job_link = page.find('.ci-failed') + expect(failed_job_link[:title]).to eq('Failed - (unknown failure)') + end + end end end - context 'with a failed job' do - let!(:build) { create(:ci_build, :coverage, :failed, pipeline: pipeline) } + describe 'with jobs_table_vue feature flag turned on', :js do + let(:project) { create(:project, :repository) } + let(:user) { create(:user) } + + before do + stub_feature_flags(jobs_table_vue: true) + + project.add_maintainer(user) + project.enable_ci + + sign_in(user) + end + + describe 'header tabs' do + before do + visit_jobs_page + end + + it 'shows a tab for All jobs and count' do + expect(page.find('[data-testid="jobs-all-tab"]').text).to include('All') + expect(page.find('[data-testid="jobs-all-tab"] .badge').text).to include('0') + end + + it 'shows a tab for Pending jobs and count' do + expect(page.find('[data-testid="jobs-pending-tab"]').text).to include('Pending') + expect(page.find('[data-testid="jobs-pending-tab"] .badge').text).to include('0') + end + + it 'shows a tab for Running jobs and count' do + expect(page.find('[data-testid="jobs-running-tab"]').text).to include('Running') + expect(page.find('[data-testid="jobs-running-tab"] .badge').text).to include('0') + end + + it 'shows a tab for Finished jobs and count' do + expect(page.find('[data-testid="jobs-finished-tab"]').text).to include('Finished') + expect(page.find('[data-testid="jobs-finished-tab"] .badge').text).to include('0') + end + + it 'updates the content when tab is clicked' do + page.find('[data-testid="jobs-finished-tab"]').click + wait_for_requests + + expect(page).to have_content('No jobs to show') + end + end + + describe 'Empty state' do + before do + visit_jobs_page + end + + it 'renders an empty state' do + expect(page).to have_content 'Use jobs to automate your tasks' + expect(page).to have_content 'Create CI/CD configuration file' + end + end + + describe 'Job actions' do + let!(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.id, ref: 'master') } + + context 'when a job can be canceled' do + let!(:job) do + create(:ci_build, pipeline: pipeline, + stage: 'test') + end + + before do + job.run + + visit_jobs_page + end + + it 'cancels a job successfully' do + page.find('[data-testid="cancel-button"]').click + + wait_for_requests + + expect(page).to have_selector('.ci-canceled') + end + end + + context 'when a job can be retried' do + let!(:job) do + create(:ci_build, pipeline: pipeline, + stage: 'test') + end + + before do + job.drop + + visit_jobs_page + end + + it 'retries a job successfully' do + page.find('[data-testid="retry"]').click + + wait_for_requests + + expect(page).to have_selector('.ci-pending') + end + end + + context 'with a scheduled job' do + let!(:scheduled_job) { create(:ci_build, :scheduled, pipeline: pipeline, name: 'build') } + + before do + visit_jobs_page + end + + it 'plays a job successfully' do + page.find('[data-testid="play-scheduled"]').click + + page.within '#play-job-modal' do + page.find_button('OK').click + end + + wait_for_requests + + expect(page).to have_selector('.ci-pending') + end + + it 'unschedules a job successfully' do + page.find('[data-testid="unschedule"]').click + + wait_for_requests + + expect(page).to have_selector('.ci-manual') + end + end + + context 'with downloadable artifacts' do + let!(:with_artifacts) do + build = create(:ci_build, :success, + pipeline: pipeline, + name: 'rspec tests', + stage: 'test') + + create(:ci_job_artifact, :codequality, job: build) + end + + before do + visit_jobs_page + end + + it 'shows the download artifacts button' do + expect(page).to have_selector('[data-testid="download-artifacts"]') + end + end + + context 'with artifacts expired' do + let!(:with_artifacts_expired) do + create(:ci_build, :expired, :success, + pipeline: pipeline, + name: 'rspec', + stage: 'test') + end + + before do + visit_jobs_page + end + + it 'does not show the download artifacts button' do + expect(page).not_to have_selector('[data-testid="download-artifacts"]') + end + end + end + + describe 'Jobs table' do + let!(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.id, ref: 'master') } + + context 'column links' do + let!(:job) do + create(:ci_build, pipeline: pipeline, + stage: 'test') + end + + before do + job.run + + visit_jobs_page + end + + it 'contains a link to the pipeline' do + expect(page.find('[data-testid="pipeline-id"]')).to have_content "##{pipeline.id}" + end + + it 'contains a link to the job sha' do + expect(page.find('[data-testid="job-sha"]')).to have_content "#{job.sha[0..7]}" + end + + it 'contains a link to the job id' do + expect(page.find('[data-testid="job-id-link"]')).to have_content "#{job.id}" + end + + it 'contains a link to the job ref' do + expect(page.find('[data-testid="job-ref"]')).to have_content "#{job.ref}" + end + end + end + + describe 'when user is not logged in' do + before do + sign_out(user) + end + + context 'when project is public' do + let(:public_project) { create(:project, :public, :repository) } + + context 'without jobs' do + it 'shows an empty state' do + visit project_jobs_path(public_project) + wait_for_requests + + expect(page).to have_content 'Use jobs to automate your tasks' + end + end + end + + context 'when project is private' do + let(:private_project) { create(:project, :private, :repository) } + + it 'redirects the user to sign_in and displays the flash alert' do + visit project_jobs_path(private_project) + wait_for_requests - it 'displays a tooltip with the failure reason' do - page.within('.ci-table') do - failed_job_link = page.find('.ci-failed') - expect(failed_job_link[:title]).to eq('Failed - (unknown failure)') + expect(page).to have_content 'You need to sign in' + expect(page.current_path).to eq("/users/sign_in") + end end end end diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index a1416f3f563..7ccd5c51493 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -46,7 +46,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do end it "shows Pending tab jobs" do - expect(page).to have_selector('.nav-links li.active', text: 'Pending') + expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Pending') expect(page).to have_content job.short_sha expect(page).to have_content job.ref expect(page).to have_content job.name @@ -60,7 +60,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do end it "shows Running tab jobs" do - expect(page).to have_selector('.nav-links li.active', text: 'Running') + expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Running') expect(page).to have_content job.short_sha expect(page).to have_content job.ref expect(page).to have_content job.name @@ -74,7 +74,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do end it "shows Finished tab jobs" do - expect(page).to have_selector('.nav-links li.active', text: 'Finished') + expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Finished') expect(page).to have_content('Use jobs to automate your tasks') end end @@ -86,7 +86,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do end it "shows All tab jobs" do - expect(page).to have_selector('.nav-links li.active', text: 'All') + expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'All') expect(page).to have_content job.short_sha expect(page).to have_content job.ref expect(page).to have_content job.name 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 84a972b3027..eb32570448b 100644 --- a/spec/features/projects/members/groups_with_access_list_spec.rb +++ b/spec/features/projects/members/groups_with_access_list_spec.rb @@ -8,6 +8,7 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group, :public) } let_it_be(:project) { create(:project, :public) } + let_it_be(:expiration_date) { 5.days.from_now.to_date } let(:additional_link_attrs) { {} } let!(:group_link) { create(:project_group_link, project: project, group: group, **additional_link_attrs) } @@ -37,27 +38,27 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do it 'updates expiry date' do page.within find_group_row(group) do - fill_in 'Expiration date', with: 5.days.from_now.to_date + fill_in 'Expiration date', with: expiration_date find_field('Expiration date').native.send_keys :enter wait_for_requests - expect(page).to have_content(/in \d days/) + expect(page).to have_field('Expiration date', with: expiration_date) end end context 'when link has expiry date set' do - let(:additional_link_attrs) { { expires_at: 5.days.from_now.to_date } } + let(:additional_link_attrs) { { expires_at: expiration_date } } it 'clears expiry date' do page.within find_group_row(group) do - expect(page).to have_content(/in \d days/) + expect(page).to have_field('Expiration date', with: expiration_date) find('[data-testid="clear-button"]').click wait_for_requests - expect(page).to have_content('No expiration set') + expect(page).to have_field('Expiration date', with: '') end end end diff --git a/spec/features/projects/members/invite_group_spec.rb b/spec/features/projects/members/invite_group_spec.rb index 8c3646125a5..b674cad0312 100644 --- a/spec/features/projects/members/invite_group_spec.rb +++ b/spec/features/projects/members/invite_group_spec.rb @@ -165,6 +165,8 @@ RSpec.describe 'Project > Members > Invite group', :js do let(:project) { create(:project) } let!(:group) { create(:group) } + let_it_be(:expiration_date) { 5.days.from_now.to_date } + around do |example| freeze_time { example.run } end @@ -176,15 +178,14 @@ RSpec.describe 'Project > Members > Invite group', :js do visit project_project_members_path(project) - invite_group(group.name, role: 'Guest', expires_at: 5.days.from_now) + invite_group(group.name, role: 'Guest', expires_at: expiration_date) end 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') + expect(page).to have_field('Expiration date', with: expiration_date) end end 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 c1b14cf60e7..830ada29a2e 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 @@ -9,6 +9,8 @@ RSpec.describe 'Projects > Members > Maintainer adds member with expiration date let_it_be(:maintainer) { create(:user) } let_it_be(:project) { create(:project) } + let_it_be(:three_days_from_now) { 3.days.from_now.to_date } + let_it_be(:five_days_from_now) { 5.days.from_now.to_date } let(:new_member) { create(:user) } @@ -22,39 +24,39 @@ RSpec.describe 'Projects > Members > Maintainer adds member with expiration date it 'expiration date is displayed in the members list' do visit project_project_members_path(project) - invite_member(new_member.name, role: 'Guest', expires_at: 5.days.from_now.to_date) + invite_member(new_member.name, role: 'Guest', expires_at: five_days_from_now) page.within find_member_row(new_member) do - expect(page).to have_content(/in \d days/) + expect(page).to have_field('Expiration date', with: five_days_from_now) end end it 'changes expiration date' do - project.team.add_users([new_member.id], :developer, expires_at: 3.days.from_now.to_date) + project.team.add_users([new_member.id], :developer, expires_at: three_days_from_now) 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 + fill_in 'Expiration date', with: five_days_from_now find_field('Expiration date').native.send_keys :enter wait_for_requests - expect(page).to have_content(/in \d days/) + expect(page).to have_field('Expiration date', with: five_days_from_now) end end it 'clears expiration date' do - project.team.add_users([new_member.id], :developer, expires_at: 5.days.from_now.to_date) + project.team.add_users([new_member.id], :developer, expires_at: five_days_from_now) visit project_project_members_path(project) page.within find_member_row(new_member) do - expect(page).to have_content(/in \d days/) + expect(page).to have_field('Expiration date', with: five_days_from_now) find('[data-testid="clear-button"]').click wait_for_requests - expect(page).to have_content('No expiration set') + expect(page).to have_field('Expiration date', with: '') end end diff --git a/spec/features/projects/navbar_spec.rb b/spec/features/projects/navbar_spec.rb index 876bc82d16c..f61eaccf5b9 100644 --- a/spec/features/projects/navbar_spec.rb +++ b/spec/features/projects/navbar_spec.rb @@ -18,6 +18,7 @@ RSpec.describe 'Project navbar' do stub_config(registry: { enabled: false }) insert_package_nav(_('Infrastructure')) insert_infrastructure_registry_nav + insert_infrastructure_google_cloud_nav end it_behaves_like 'verified navigation bar' do diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index 39f9d3b331b..dacbaa826a0 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -296,12 +296,16 @@ RSpec.describe 'New project', :js do expect(git_import_instructions).to have_content 'Git repository URL' end - it 'reports error if repo URL does not end with .git' do + it 'reports error if repo URL is not a valid Git repository' do + stub_request(:get, "http://foo/bar/info/refs?service=git-upload-pack").to_return(status: 200, body: "not-a-git-repo") + fill_in 'project_import_url', with: 'http://foo/bar' # simulate blur event find('body').click - expect(page).to have_text('A repository URL usually ends in a .git suffix') + wait_for_requests + + expect(page).to have_text('There is not a valid Git repository at this URL') end it 'keeps "Import project" tab open after form validation error' do diff --git a/spec/features/projects/packages_spec.rb b/spec/features/projects/packages_spec.rb index 7fcc8200b1c..9b1e87192f5 100644 --- a/spec/features/projects/packages_spec.rb +++ b/spec/features/projects/packages_spec.rb @@ -27,6 +27,10 @@ RSpec.describe 'Packages' do context 'when feature is available', :js do before do + # we are simply setting the featrure flag to false because the new UI has nothing to test yet + # when the refactor is complete or almost complete we will turn on the feature tests + # see https://gitlab.com/gitlab-org/gitlab/-/issues/330846 for status of this work + stub_feature_flags(package_list_apollo: false) visit_project_packages end diff --git a/spec/features/projects/settings/monitor_settings_spec.rb b/spec/features/projects/settings/monitor_settings_spec.rb index e3d75c30e5e..3f6c4646f00 100644 --- a/spec/features/projects/settings/monitor_settings_spec.rb +++ b/spec/features/projects/settings/monitor_settings_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe 'Projects > Settings > For a forked project', :js do let_it_be(:project) { create(:project, :repository, create_templates: :issue) } - let(:user) { project.owner} + let(:user) { project.owner } before do sign_in(user) @@ -16,7 +16,8 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do visit project_path(project) wait_for_requests - expect(page).to have_selector('.sidebar-sub-level-items a[aria-label="Monitor"]', text: 'Monitor', visible: false) + expect(page).to have_selector('.sidebar-sub-level-items a[aria-label="Monitor"]', + text: 'Monitor', visible: :hidden) end end @@ -42,7 +43,7 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do expect(find_field(send_email)).to be_checked end - it 'updates form values', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/333665' do + it 'updates form values' do check(create_issue) uncheck(send_email) click_on('No template selected') @@ -52,10 +53,8 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do click_settings_tab expect(find_field(create_issue)).to be_checked - expect(page).to have_selector(:id, 'alert-integration-settings-issue-template', text: 'bug') - - click_settings_tab expect(find_field(send_email)).not_to be_checked + expect(page).to have_selector(:id, 'alert-integration-settings-issue-template', text: 'bug') end def click_settings_tab @@ -68,13 +67,15 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do page.within '[data-testid="alert-integration-settings"]' do click_button 'Save changes' end + + wait_for_all_requests end end - context 'error tracking settings form' do + describe 'error tracking settings form' do let(:sentry_list_projects_url) { 'http://sentry.example.com/api/0/projects/' } - context 'success path' do + context 'when project dropdown is loaded' do let(:projects_sample_response) do Gitlab::Utils.deep_indifferent_access( Gitlab::Json.parse(fixture_file('sentry/list_projects_sample_response.json')) @@ -97,7 +98,9 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do within '.js-error-tracking-settings' do click_button('Expand') + choose('cloud-hosted Sentry') end + expect(page).to have_content('Sentry API URL') expect(page.body).to include('Error Tracking') expect(page).to have_button('Connect') @@ -121,7 +124,7 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do end end - context 'project dropdown fails to load' do + context 'when project dropdown fails to load' do before do WebMock.stub_request(:get, sentry_list_projects_url) .to_return( @@ -140,8 +143,10 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do within '.js-error-tracking-settings' do click_button('Expand') + choose('cloud-hosted Sentry') + check('Active') end - check('Active') + fill_in('error-tracking-api-host', with: 'http://sentry.example.com') fill_in('error-tracking-token', with: 'token') @@ -151,7 +156,7 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do end end - context 'integrated error tracking backend' do + context 'with integrated error tracking backend' do it 'successfully fills and submits the form' do visit project_settings_operations_path(project) @@ -175,11 +180,17 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do wait_for_requests assert_text('Your changes have been saved') + + within '.js-error-tracking-settings' do + click_button('Expand') + end + + expect(page).to have_content('Paste this DSN into your Sentry SDK') end end end - context 'grafana integration settings form' do + describe 'grafana integration settings form' do it 'successfully fills and completes the form' do visit project_settings_operations_path(project) diff --git a/spec/features/projects/settings/webhooks_settings_spec.rb b/spec/features/projects/settings/webhooks_settings_spec.rb index 528fd58cbe6..8d73ffecd46 100644 --- a/spec/features/projects/settings/webhooks_settings_spec.rb +++ b/spec/features/projects/settings/webhooks_settings_spec.rb @@ -115,8 +115,8 @@ RSpec.describe 'Projects > Settings > Webhook Settings' do hook_log visit edit_project_hook_path(project, hook) - expect(page).to have_content('Recent Deliveries') - expect(page).to have_content(hook_log.url) + expect(page).to have_content('Recent events') + expect(page).to have_link('View details', href: hook_log.present.details_path) end it 'show hook log details' do diff --git a/spec/features/projects/show/user_uploads_files_spec.rb b/spec/features/projects/show/user_uploads_files_spec.rb index eb230082bfa..51e41397439 100644 --- a/spec/features/projects/show/user_uploads_files_spec.rb +++ b/spec/features/projects/show/user_uploads_files_spec.rb @@ -21,13 +21,15 @@ RSpec.describe 'Projects > Show > User uploads files' do wait_for_requests end - include_examples 'it uploads and commits a new text file' + [true, false].each do |value| + include_examples 'it uploads and commits a new text file', drop: value - include_examples 'it uploads and commits a new image file' + include_examples 'it uploads and commits a new image file', drop: value - include_examples 'it uploads and commits a new pdf file' + include_examples 'it uploads and commits a new pdf file', drop: value - include_examples 'it uploads a file to a sub-directory' + include_examples 'it uploads a file to a sub-directory', drop: value + end end context 'when a user does not have write access' do @@ -37,7 +39,9 @@ RSpec.describe 'Projects > Show > User uploads files' do visit(project_path(project2)) end - include_examples 'it uploads and commits a new file to a forked project' + [true, false].each do |value| + include_examples 'it uploads and commits a new file to a forked project', drop: value + end end context 'when in the empty_repo_upload experiment' do @@ -50,13 +54,17 @@ RSpec.describe 'Projects > Show > User uploads files' do 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' + [true, false].each do |value| + include_examples 'uploads and commits a new text file via "upload file" button', drop: value + end 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' + [true, false].each do |value| + include_examples 'uploads and commits a new text file via "upload file" button', drop: value + end end end end diff --git a/spec/features/projects/user_creates_project_spec.rb b/spec/features/projects/user_creates_project_spec.rb index 9f08759603e..5d482f9fbd0 100644 --- a/spec/features/projects/user_creates_project_spec.rb +++ b/spec/features/projects/user_creates_project_spec.rb @@ -33,6 +33,29 @@ RSpec.describe 'User creates a project', :js do expect(page).to have_content(project.url_to_repo) end + it 'creates a new project that is not blank' do + stub_experiments(new_project_sast_enabled: 'candidate') + + visit(new_project_path) + + find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + fill_in(:project_name, with: 'With initial commits') + + expect(page).to have_checked_field 'Initialize repository with a README' + expect(page).to have_checked_field 'Enable Static Application Security Testing (SAST)' + + page.within('#content-body') do + click_button('Create project') + end + + project = Project.last + + expect(current_path).to eq(project_path(project)) + expect(page).to have_content('With initial commits') + expect(page).to have_content('Configure SAST in `.gitlab-ci.yml`, creating this file if it does not already exist') + expect(page).to have_content('README.md Initial commit') + end + context 'in a subgroup they do not own' do let(:parent) { create(:group) } let!(:subgroup) { create(:group, parent: parent) } diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index 9dcef13757a..4012a302196 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -553,6 +553,7 @@ RSpec.describe "Internal Project Access" do before do stub_container_registry_tags(repository: :any, tags: ['latest']) stub_container_registry_config(enabled: true) + stub_container_registry_info project.container_repositories << container_repository end diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index 5a200bea80a..aa34ccce2c1 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -570,6 +570,7 @@ RSpec.describe "Private Project Access" do before do stub_container_registry_tags(repository: :any, tags: ['latest']) stub_container_registry_config(enabled: true) + stub_container_registry_info project.container_repositories << container_repository end diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index 8ceb6920e77..abe128c6f78 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -552,6 +552,7 @@ RSpec.describe "Public Project Access" do before do stub_container_registry_tags(repository: :any, tags: ['latest']) stub_container_registry_config(enabled: true) + stub_container_registry_info project.container_repositories << container_repository end diff --git a/spec/features/snippets/notes_on_personal_snippets_spec.rb b/spec/features/snippets/notes_on_personal_snippets_spec.rb index e03f71c5352..fc88cd9205c 100644 --- a/spec/features/snippets/notes_on_personal_snippets_spec.rb +++ b/spec/features/snippets/notes_on_personal_snippets_spec.rb @@ -70,8 +70,8 @@ RSpec.describe 'Comments on personal snippets', :js do context 'when submitting a note' do it 'shows a valid form' do is_expected.to have_css('.js-main-target-form', visible: true, count: 1) - expect(find('.js-main-target-form .js-comment-button').value) - .to eq('Comment') + expect(find('.js-main-target-form .js-comment-button button', match: :first)) + .to have_content('Comment') page.within('.js-main-target-form') do expect(page).not_to have_link('Cancel') diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb index 79c4057a8b9..10c1c2cb26e 100644 --- a/spec/features/users/login_spec.rb +++ b/spec/features/users/login_spec.rb @@ -171,6 +171,18 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do end end + describe 'with OneTrust authentication' do + before do + stub_config(extra: { one_trust_id: SecureRandom.uuid }) + end + + it 'has proper Content-Security-Policy headers' do + visit root_path + + expect(response_headers['Content-Security-Policy']).to include('https://cdn.cookielaw.org https://*.onetrust.com') + end + end + describe 'with two-factor authentication', :js do def enter_code(code) fill_in 'user_otp_attempt', with: code @@ -866,8 +878,8 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do expect(current_path).to eq(new_profile_password_path) - fill_in 'user_current_password', with: '12345678' - fill_in 'user_password', with: 'new password' + fill_in 'user_password', with: '12345678' + fill_in 'user_new_password', with: 'new password' fill_in 'user_password_confirmation', with: 'new password' click_button 'Set new password' @@ -875,7 +887,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do end end - context 'when the user does not have an email configured' do + context 'when the user does not have an email configured', :js do let(:user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'saml', email: 'temp-email-for-oauth-user@gitlab.localhost') } before do diff --git a/spec/features/users/show_spec.rb b/spec/features/users/show_spec.rb index e629d329033..61672662fbe 100644 --- a/spec/features/users/show_spec.rb +++ b/spec/features/users/show_spec.rb @@ -81,6 +81,7 @@ RSpec.describe 'User page' do context 'timezone' do let_it_be(:timezone) { 'America/Los_Angeles' } + let_it_be(:local_time_selector) { '[data-testid="user-local-time"]' } before do travel_to Time.find_zone(timezone).local(2021, 7, 20, 15, 30, 45) @@ -92,7 +93,19 @@ RSpec.describe 'User page' do it 'shows local time' do subject - expect(page).to have_content('3:30 PM') + within local_time_selector do + expect(page).to have_content('3:30 PM') + end + end + end + + context 'when timezone is not set' do + let_it_be(:user) { create(:user, timezone: nil) } + + it 'does not show local time' do + subject + + expect(page).not_to have_selector(local_time_selector) end end @@ -102,7 +115,9 @@ RSpec.describe 'User page' do it 'shows local time using the configured default timezone (UTC in this case)' do subject - expect(page).to have_content('10:30 PM') + within local_time_selector do + expect(page).to have_content('10:30 PM') + end end end end diff --git a/spec/finders/ci/commit_statuses_finder_spec.rb b/spec/finders/ci/commit_statuses_finder_spec.rb index 2e26e38f4b4..9f66b53dd1f 100644 --- a/spec/finders/ci/commit_statuses_finder_spec.rb +++ b/spec/finders/ci/commit_statuses_finder_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Ci::CommitStatusesFinder, '#execute' do let_it_be(:user) { create(:user) } context 'tag refs' do - let_it_be(:tags) { TagsFinder.new(project.repository, {}).execute } + let_it_be(:tags) { project.repository.tags } let(:subject) { described_class.new(project, project.repository, user, tags).execute } @@ -131,7 +131,7 @@ RSpec.describe Ci::CommitStatusesFinder, '#execute' do end context 'CI pipelines visible to' do - let_it_be(:tags) { TagsFinder.new(project.repository, {}).execute } + let_it_be(:tags) { project.repository.tags } let(:subject) { described_class.new(project, project.repository, user, tags).execute } @@ -161,7 +161,7 @@ RSpec.describe Ci::CommitStatusesFinder, '#execute' do context 'when not a member of a private project' do let(:private_project) { create(:project, :private, :repository) } - let(:private_tags) { TagsFinder.new(private_tags.repository, {}).execute } + let(:private_tags) { private_tags.repository.tags } let(:private_subject) { described_class.new(private_project, private_project.repository, user, tags).execute } before do diff --git a/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb b/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb index 65f6dc0ba74..8a802e9660b 100644 --- a/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb +++ b/spec/finders/ci/pipelines_for_merge_request_finder_spec.rb @@ -44,7 +44,7 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do let(:actor) { developer_in_both } it 'returns all pipelines' do - is_expected.to eq([pipeline_in_fork, pipeline_in_parent]) + is_expected.to match_array([pipeline_in_fork, pipeline_in_parent]) end end @@ -52,7 +52,7 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do let(:actor) { reporter_in_parent_and_developer_in_fork } it 'returns all pipelines' do - is_expected.to eq([pipeline_in_fork, pipeline_in_parent]) + is_expected.to match_array([pipeline_in_fork, pipeline_in_parent]) end end @@ -60,7 +60,7 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do let(:actor) { developer_in_parent } it 'returns pipelines in parent' do - is_expected.to eq([pipeline_in_parent]) + is_expected.to match_array([pipeline_in_parent]) end end @@ -68,7 +68,7 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do let(:actor) { developer_in_fork } it 'returns pipelines in fork' do - is_expected.to eq([pipeline_in_fork]) + is_expected.to match_array([pipeline_in_fork]) end end @@ -97,7 +97,7 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do shared_examples 'returning pipelines with proper ordering' do let!(:all_pipelines) do - merge_request.all_commit_shas.map do |sha| + merge_request.recent_diff_head_shas.map do |sha| create(:ci_empty_pipeline, project: project, sha: sha, ref: merge_request.source_branch) end @@ -135,12 +135,92 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do end context 'when pipelines exist for the branch and merge request' do + shared_examples 'returns all pipelines for merge request' do + it 'returns merge request pipeline first' do + expect(subject.all).to eq([detached_merge_request_pipeline, branch_pipeline]) + end + + context 'when there are a branch pipeline and a merge request pipeline' do + let!(:branch_pipeline_2) do + create(:ci_pipeline, source: :push, project: project, + ref: source_ref, sha: shas.first) + end + + let!(:detached_merge_request_pipeline_2) do + create(:ci_pipeline, source: :merge_request_event, project: project, + ref: source_ref, sha: shas.first, merge_request: merge_request) + end + + it 'returns merge request pipelines first' do + expect(subject.all) + .to eq([detached_merge_request_pipeline_2, + detached_merge_request_pipeline, + branch_pipeline_2, + branch_pipeline]) + end + end + + context 'when there are multiple merge request pipelines from the same branch' do + let!(:branch_pipeline_2) do + create(:ci_pipeline, source: :push, project: project, + ref: source_ref, sha: shas.first) + end + + let!(:branch_pipeline_with_sha_not_belonging_to_merge_request) do + create(:ci_pipeline, source: :push, project: project, ref: source_ref) + end + + let!(:detached_merge_request_pipeline_2) do + create(:ci_pipeline, source: :merge_request_event, project: project, + ref: source_ref, sha: shas.first, merge_request: merge_request_2) + end + + let(:merge_request_2) do + create(:merge_request, source_project: project, source_branch: source_ref, + target_project: project, target_branch: 'stable') + end + + before do + shas.each.with_index do |sha, index| + create(:merge_request_diff_commit, + merge_request_diff: merge_request_2.merge_request_diff, + sha: sha, relative_order: index) + end + end + + it 'returns only related merge request pipelines' do + expect(subject.all) + .to eq([detached_merge_request_pipeline, + branch_pipeline_2, + branch_pipeline]) + + expect(described_class.new(merge_request_2, nil).all) + .to match_array([detached_merge_request_pipeline_2, branch_pipeline_2, branch_pipeline]) + end + end + + context 'when detached merge request pipeline is run on head ref of the merge request' do + let!(:detached_merge_request_pipeline) do + create(:ci_pipeline, source: :merge_request_event, project: project, + ref: merge_request.ref_path, sha: shas.second, merge_request: merge_request) + end + + it 'sets the head ref of the merge request to the pipeline ref' do + expect(detached_merge_request_pipeline.ref).to match(%r{refs/merge-requests/\d+/head}) + end + + it 'includes the detached merge request pipeline even though the ref is custom path' do + expect(merge_request.all_pipelines).to include(detached_merge_request_pipeline) + end + end + end + let(:source_ref) { 'feature' } let(:target_ref) { 'master' } let!(:branch_pipeline) do create(:ci_pipeline, source: :push, project: project, - ref: source_ref, sha: shas.second) + ref: source_ref, sha: merge_request.merge_request_diff.head_commit_sha) end let!(:tag_pipeline) do @@ -149,97 +229,31 @@ RSpec.describe Ci::PipelinesForMergeRequestFinder do let!(:detached_merge_request_pipeline) do create(:ci_pipeline, source: :merge_request_event, project: project, - ref: source_ref, sha: shas.second, merge_request: merge_request) + ref: source_ref, sha: shas.second, merge_request: merge_request) end let(:merge_request) do create(:merge_request, source_project: project, source_branch: source_ref, - target_project: project, target_branch: target_ref) + target_project: project, target_branch: target_ref) end let(:project) { create(:project, :repository) } let(:shas) { project.repository.commits(source_ref, limit: 2).map(&:id) } - before do - create(:merge_request_diff_commit, - merge_request_diff: merge_request.merge_request_diff, - sha: shas.second, relative_order: 1) - end - - it 'returns merge request pipeline first' do - expect(subject.all).to eq([detached_merge_request_pipeline, branch_pipeline]) - end - - context 'when there are a branch pipeline and a merge request pipeline' do - let!(:branch_pipeline_2) do - create(:ci_pipeline, source: :push, project: project, - ref: source_ref, sha: shas.first) - end - - let!(:detached_merge_request_pipeline_2) do - create(:ci_pipeline, source: :merge_request_event, project: project, - ref: source_ref, sha: shas.first, merge_request: merge_request) - end - - it 'returns merge request pipelines first' do - expect(subject.all) - .to eq([detached_merge_request_pipeline_2, - detached_merge_request_pipeline, - branch_pipeline_2, - branch_pipeline]) - end - end - - context 'when there are multiple merge request pipelines from the same branch' do - let!(:branch_pipeline_2) do - create(:ci_pipeline, source: :push, project: project, - ref: source_ref, sha: shas.first) - end - - let!(:detached_merge_request_pipeline_2) do - create(:ci_pipeline, source: :merge_request_event, project: project, - ref: source_ref, sha: shas.first, merge_request: merge_request_2) - end - - let(:merge_request_2) do - create(:merge_request, source_project: project, source_branch: source_ref, - target_project: project, target_branch: 'stable') - end - + context 'when `decomposed_ci_query_in_pipelines_for_merge_request_finder` feature flag enabled' do before do - shas.each.with_index do |sha, index| - create(:merge_request_diff_commit, - merge_request_diff: merge_request_2.merge_request_diff, - sha: sha, relative_order: index) - end + stub_feature_flags(decomposed_ci_query_in_pipelines_for_merge_request_finder: merge_request.target_project) end - it 'returns only related merge request pipelines' do - expect(subject.all) - .to eq([detached_merge_request_pipeline, - branch_pipeline_2, - branch_pipeline]) - - expect(described_class.new(merge_request_2, nil).all) - .to eq([detached_merge_request_pipeline_2, - branch_pipeline_2, - branch_pipeline]) - end + it_behaves_like 'returns all pipelines for merge request' end - context 'when detached merge request pipeline is run on head ref of the merge request' do - let!(:detached_merge_request_pipeline) do - create(:ci_pipeline, source: :merge_request_event, project: project, - ref: merge_request.ref_path, sha: shas.second, merge_request: merge_request) - end - - it 'sets the head ref of the merge request to the pipeline ref' do - expect(detached_merge_request_pipeline.ref).to match(%r{refs/merge-requests/\d+/head}) + context 'when `decomposed_ci_query_in_pipelines_for_merge_request_finder` feature flag disabled' do + before do + stub_feature_flags(decomposed_ci_query_in_pipelines_for_merge_request_finder: false) end - it 'includes the detached merge request pipeline even though the ref is custom path' do - expect(merge_request.all_pipelines).to include(detached_merge_request_pipeline) - end + it_behaves_like 'returns all pipelines for merge request' end end end diff --git a/spec/finders/clusters/agents_finder_spec.rb b/spec/finders/clusters/agents_finder_spec.rb new file mode 100644 index 00000000000..0996d76b723 --- /dev/null +++ b/spec/finders/clusters/agents_finder_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::AgentsFinder do + describe '#execute' do + let(:project) { create(:project) } + let(:user) { create(:user, maintainer_projects: [project]) } + + let!(:matching_agent) { create(:cluster_agent, project: project) } + let!(:wrong_project) { create(:cluster_agent) } + + subject { described_class.new(project, user).execute } + + it { is_expected.to contain_exactly(matching_agent) } + + context 'user does not have permission' do + let(:user) { create(:user, developer_projects: [project]) } + + it { is_expected.to be_empty } + end + + context 'filtering by name' do + let(:params) { Hash(name: name_param) } + + subject { described_class.new(project, user, params: params).execute } + + context 'name does not match' do + let(:name_param) { 'other-name' } + + it { is_expected.to be_empty } + end + + context 'name does match' do + let(:name_param) { matching_agent.name } + + it { is_expected.to contain_exactly(matching_agent) } + end + end + end +end diff --git a/spec/finders/error_tracking/errors_finder_spec.rb b/spec/finders/error_tracking/errors_finder_spec.rb index 29053054f9d..66eb7769a4c 100644 --- a/spec/finders/error_tracking/errors_finder_spec.rb +++ b/spec/finders/error_tracking/errors_finder_spec.rb @@ -29,14 +29,25 @@ RSpec.describe ErrorTracking::ErrorsFinder do context 'with sort parameter' do let(:params) { { status: 'unresolved', sort: 'first_seen' } } - it { is_expected.to eq([error, error_yesterday]) } + it { expect(subject.to_a).to eq([error, error_yesterday]) } end - context 'with limit parameter' do + context 'pagination' do let(:params) { { limit: '1', sort: 'first_seen' } } # Sort by first_seen is DESC by default, so the most recent error is `error` it { is_expected.to contain_exactly(error) } + + it { expect(subject.has_next_page?).to be_truthy } + + it 'returns next page by cursor' do + params_with_cursor = params.merge(cursor: subject.cursor_for_next_page) + errors = described_class.new(user, project, params_with_cursor).execute + + expect(errors).to contain_exactly(error_resolved) + expect(errors.has_next_page?).to be_truthy + expect(errors.has_previous_page?).to be_truthy + end end end end diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index ed35d75720c..9f12308013e 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -1199,6 +1199,14 @@ RSpec.describe IssuesFinder do end end + context 'when a non-simple sort is given' do + let(:params) { { search: 'foo', attempt_project_search_optimizations: true, sort: 'popularity' } } + + it 'returns false' do + expect(finder.use_cte_for_search?).to be_falsey + end + end + context 'when all conditions are met' do context "uses group search optimization" do let(:params) { { search: 'foo', attempt_group_search_optimizations: true } } @@ -1217,6 +1225,24 @@ RSpec.describe IssuesFinder do expect(finder.execute.to_sql).to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/) end end + + context 'with simple sort' do + let(:params) { { search: 'foo', attempt_project_search_optimizations: true, sort: 'updated_desc' } } + + it 'returns true' do + expect(finder.use_cte_for_search?).to be_truthy + expect(finder.execute.to_sql).to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/) + end + end + + context 'with simple sort as a symbol' do + let(:params) { { search: 'foo', attempt_project_search_optimizations: true, sort: :updated_desc } } + + it 'returns true' do + expect(finder.use_cte_for_search?).to be_truthy + expect(finder.execute.to_sql).to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/) + end + end end end diff --git a/spec/finders/members_finder_spec.rb b/spec/finders/members_finder_spec.rb index d25e1b9ca4b..749e319f9c7 100644 --- a/spec/finders/members_finder_spec.rb +++ b/spec/finders/members_finder_spec.rb @@ -161,42 +161,54 @@ RSpec.describe MembersFinder, '#execute' do end context 'when :invited_groups is passed' do - subject { described_class.new(project, user2).execute(include_relations: [:inherited, :direct, :invited_groups]) } + shared_examples 'with invited_groups param' do + subject { described_class.new(project, user2).execute(include_relations: [:inherited, :direct, :invited_groups]) } - let_it_be(:linked_group) { create(:group, :public) } - let_it_be(:nested_linked_group) { create(:group, parent: linked_group) } - let_it_be(:linked_group_member) { linked_group.add_guest(user1) } - let_it_be(:nested_linked_group_member) { nested_linked_group.add_guest(user2) } + let_it_be(:linked_group) { create(:group, :public) } + let_it_be(:nested_linked_group) { create(:group, parent: linked_group) } + let_it_be(:linked_group_member) { linked_group.add_guest(user1) } + let_it_be(:nested_linked_group_member) { nested_linked_group.add_guest(user2) } - it 'includes all the invited_groups members including members inherited from ancestor groups' do - create(:project_group_link, project: project, group: nested_linked_group) + it 'includes all the invited_groups members including members inherited from ancestor groups' do + create(:project_group_link, project: project, group: nested_linked_group) - expect(subject).to contain_exactly(linked_group_member, nested_linked_group_member) - end + expect(subject).to contain_exactly(linked_group_member, nested_linked_group_member) + end - it 'includes all the invited_groups members' do - create(:project_group_link, project: project, group: linked_group) + it 'includes all the invited_groups members' do + create(:project_group_link, project: project, group: linked_group) - expect(subject).to contain_exactly(linked_group_member) - end + expect(subject).to contain_exactly(linked_group_member) + end - it 'excludes group_members not visible to the user' do - create(:project_group_link, project: project, group: linked_group) - private_linked_group = create(:group, :private) - private_linked_group.add_developer(user3) - create(:project_group_link, project: project, group: private_linked_group) + it 'excludes group_members not visible to the user' do + create(:project_group_link, project: project, group: linked_group) + private_linked_group = create(:group, :private) + private_linked_group.add_developer(user3) + create(:project_group_link, project: project, group: private_linked_group) - expect(subject).to contain_exactly(linked_group_member) + expect(subject).to contain_exactly(linked_group_member) + end + + context 'when the user is a member of invited group and ancestor groups' do + it 'returns the highest access_level for the user limited by project_group_link.group_access', :nested_groups do + create(:project_group_link, project: project, group: nested_linked_group, group_access: Gitlab::Access::REPORTER) + nested_linked_group.add_developer(user1) + + expect(subject.map(&:user)).to contain_exactly(user1, user2) + expect(subject.max_by(&:access_level).access_level).to eq(Gitlab::Access::REPORTER) + end + end end - context 'when the user is a member of invited group and ancestor groups' do - it 'returns the highest access_level for the user limited by project_group_link.group_access', :nested_groups do - create(:project_group_link, project: project, group: nested_linked_group, group_access: Gitlab::Access::REPORTER) - nested_linked_group.add_developer(user1) + it_behaves_like 'with invited_groups param' - expect(subject.map(&:user)).to contain_exactly(user1, user2) - expect(subject.max_by(&:access_level).access_level).to eq(Gitlab::Access::REPORTER) + context 'when feature flag :linear_members_finder_ancestor_scopes is disabled' do + before do + stub_feature_flags(linear_members_finder_ancestor_scopes: false) end + + it_behaves_like 'with invited_groups param' end end end diff --git a/spec/finders/packages/group_packages_finder_spec.rb b/spec/finders/packages/group_packages_finder_spec.rb index d7f62bdfbb4..3254c436674 100644 --- a/spec/finders/packages/group_packages_finder_spec.rb +++ b/spec/finders/packages/group_packages_finder_spec.rb @@ -147,6 +147,19 @@ RSpec.describe Packages::GroupPackagesFinder do end end + context 'with exact package_name' do + let_it_be(:named_package) { create(:maven_package, project: project, name: 'maven') } + let_it_be(:other_package) { create(:maven_package, project: project, name: 'maventoo') } + + let(:params) { { exact_name: true, package_name: package_name } } + + context 'as complete name' do + let(:package_name) { 'maven' } + + it { is_expected.to match_array([named_package]) } + end + end + it_behaves_like 'concerning versionless param' it_behaves_like 'concerning package statuses' end diff --git a/spec/finders/projects/members/effective_access_level_finder_spec.rb b/spec/finders/projects/members/effective_access_level_finder_spec.rb index 1112dbd0d6e..33fbb5aca30 100644 --- a/spec/finders/projects/members/effective_access_level_finder_spec.rb +++ b/spec/finders/projects/members/effective_access_level_finder_spec.rb @@ -194,6 +194,7 @@ RSpec.describe Projects::Members::EffectiveAccessLevelFinder, '#execute' do context 'for a project that is shared with other group(s)' do let_it_be(:shared_with_group) { create(:group) } let_it_be(:user_from_shared_with_group) { create(:user) } + let_it_be(:project) { create(:project, group: create(:group)) } before do create(:project_group_link, :developer, project: project, group: shared_with_group) @@ -211,9 +212,24 @@ RSpec.describe Projects::Members::EffectiveAccessLevelFinder, '#execute' do ) end - context 'when the group containing the project has forbidden group shares for any of its projects' do - let_it_be(:project) { create(:project, group: create(:group)) } + context 'even when the `lock_memberships_to_ldap` setting has been turned ON' do + before do + stub_application_setting(lock_memberships_to_ldap: true) + end + it 'includes the least among the specified access levels' do + expect(subject).to( + include( + hash_including( + 'user_id' => user_from_shared_with_group.id, + 'access_level' => Gitlab::Access::DEVELOPER + ) + ) + ) + end + end + + context 'when the group containing the project has forbidden group shares for any of its projects' do before do project.namespace.update!(share_with_group_lock: true) end diff --git a/spec/finders/projects/topics_finder_spec.rb b/spec/finders/projects/topics_finder_spec.rb new file mode 100644 index 00000000000..28802c5d49e --- /dev/null +++ b/spec/finders/projects/topics_finder_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::TopicsFinder do + let_it_be(:user) { create(:user) } + + let!(:topic1) { create(:topic, name: 'topicB') } + let!(:topic2) { create(:topic, name: 'topicC') } + let!(:topic3) { create(:topic, name: 'topicA') } + + let!(:project1) { create(:project, namespace: user.namespace, topic_list: 'topicC, topicA, topicB') } + let!(:project2) { create(:project, namespace: user.namespace, topic_list: 'topicC, topicA') } + let!(:project3) { create(:project, namespace: user.namespace, topic_list: 'topicC') } + + describe '#execute' do + it 'returns topics' do + topics = described_class.new.execute + + expect(topics).to eq([topic2, topic3, topic1]) + end + + context 'filter by name' do + using RSpec::Parameterized::TableSyntax + + where(:search, :result) do + 'topic' | %w[topicC topicA topicB] + 'pic' | %w[topicC topicA topicB] + 'B' | %w[] + 'cB' | %w[] + 'icB' | %w[topicB] + 'topicA' | %w[topicA] + 'topica' | %w[topicA] + end + + with_them do + it 'returns filtered topics' do + topics = described_class.new(params: { search: search }).execute + + expect(topics.map(&:name)).to eq(result) + end + end + end + end +end diff --git a/spec/finders/tags_finder_spec.rb b/spec/finders/tags_finder_spec.rb index 08978a32e50..fe015d53ac9 100644 --- a/spec/finders/tags_finder_spec.rb +++ b/spec/finders/tags_finder_spec.rb @@ -3,93 +3,76 @@ require 'spec_helper' RSpec.describe TagsFinder do - let(:user) { create(:user) } - let(:project) { create(:project, :repository) } - let(:repository) { project.repository } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:repository) { project.repository } + + def load_tags(params) + tags_finder = described_class.new(repository, params) + tags, error = tags_finder.execute + + expect(error).to eq(nil) + + tags + end describe '#execute' do context 'sort only' do it 'sorts by name' do - tags_finder = described_class.new(repository, {}) - - result = tags_finder.execute - - expect(result.first.name).to eq("v1.0.0") + expect(load_tags({}).first.name).to eq("v1.0.0") end it 'sorts by recently_updated' do - tags_finder = described_class.new(repository, { sort: 'updated_desc' }) - - result = tags_finder.execute recently_updated_tag = repository.tags.max do |a, b| repository.commit(a.dereferenced_target).committed_date <=> repository.commit(b.dereferenced_target).committed_date end - expect(result.first.name).to eq(recently_updated_tag.name) + params = { sort: 'updated_desc' } + + expect(load_tags(params).first.name).to eq(recently_updated_tag.name) end it 'sorts by last_updated' do - tags_finder = described_class.new(repository, { sort: 'updated_asc' }) - - result = tags_finder.execute + params = { sort: 'updated_asc' } - expect(result.first.name).to eq('v1.0.0') + expect(load_tags(params).first.name).to eq('v1.0.0') end end context 'filter only' do it 'filters tags by name' do - tags_finder = described_class.new(repository, { search: '1.0.0' }) - - result = tags_finder.execute + result = load_tags({ search: '1.0.0' }) expect(result.first.name).to eq('v1.0.0') expect(result.count).to eq(1) end it 'does not find any tags with that name' do - tags_finder = described_class.new(repository, { search: 'hey' }) - - result = tags_finder.execute - - expect(result.count).to eq(0) + expect(load_tags({ search: 'hey' }).count).to eq(0) end it 'filters tags by name that begins with' do - params = { search: '^v1.0' } - tags_finder = described_class.new(repository, params) - - result = tags_finder.execute + result = load_tags({ search: '^v1.0' }) expect(result.first.name).to eq('v1.0.0') expect(result.count).to eq(1) end it 'filters tags by name that ends with' do - params = { search: '0.0$' } - tags_finder = described_class.new(repository, params) - - result = tags_finder.execute + result = load_tags({ search: '0.0$' }) expect(result.first.name).to eq('v1.0.0') expect(result.count).to eq(1) end it 'filters tags by nonexistent name that begins with' do - params = { search: '^nope' } - tags_finder = described_class.new(repository, params) - - result = tags_finder.execute + result = load_tags({ search: '^nope' }) expect(result.count).to eq(0) end it 'filters tags by nonexistent name that ends with' do - params = { search: 'nope$' } - tags_finder = described_class.new(repository, params) - - result = tags_finder.execute - + result = load_tags({ search: 'nope$' }) expect(result.count).to eq(0) end end @@ -97,7 +80,7 @@ RSpec.describe TagsFinder do context 'filter and sort' do let(:tags_to_compare) { %w[v1.0.0 v1.1.0] } - subject { described_class.new(repository, params).execute.select { |tag| tags_to_compare.include?(tag.name) } } + subject { load_tags(params).select { |tag| tags_to_compare.include?(tag.name) } } context 'when sort by updated_desc' do let(:params) { { sort: 'updated_desc', search: 'v1' } } @@ -117,5 +100,17 @@ RSpec.describe TagsFinder do end end end + + context 'when Gitaly is unavailable' do + it 'returns empty list of tags' do + expect(Gitlab::GitalyClient).to receive(:call).and_raise(GRPC::Unavailable) + + tags_finder = described_class.new(repository, {}) + tags, error = tags_finder.execute + + expect(error).to be_a(Gitlab::Git::CommandError) + expect(tags).to eq([]) + end + end end end diff --git a/spec/fixtures/api/schemas/entities/member.json b/spec/fixtures/api/schemas/entities/member.json index f06687f9809..dec98123e85 100644 --- a/spec/fixtures/api/schemas/entities/member.json +++ b/spec/fixtures/api/schemas/entities/member.json @@ -56,13 +56,15 @@ { "$ref": "member_user.json" } ] }, + "state": { "type": "integer" }, "invite": { "type": "object", - "required": ["email", "avatar_url", "can_resend"], + "required": ["email", "avatar_url", "can_resend", "user_state"], "properties": { "email": { "type": "string" }, "avatar_url": { "type": "string" }, - "can_resend": { "type": "boolean" } + "can_resend": { "type": "boolean" }, + "user_state": { "type": "string" } }, "additionalProperties": false } diff --git a/spec/fixtures/api/schemas/external_validation.json b/spec/fixtures/api/schemas/external_validation.json index 280b77b221a..e95909a2922 100644 --- a/spec/fixtures/api/schemas/external_validation.json +++ b/spec/fixtures/api/schemas/external_validation.json @@ -34,7 +34,8 @@ "email": { "type": "string" }, "created_at": { "type": ["string", "null"], "format": "date-time" }, "current_sign_in_ip": { "type": ["string", "null"] }, - "last_sign_in_ip": { "type": ["string", "null"] } + "last_sign_in_ip": { "type": ["string", "null"] }, + "sign_in_count": { "type": "integer" } } }, "pipeline": { diff --git a/spec/fixtures/api/schemas/graphql/packages/package_details.json b/spec/fixtures/api/schemas/graphql/packages/package_details.json index 9e8bf7c52d0..2824ca64325 100644 --- a/spec/fixtures/api/schemas/graphql/packages/package_details.json +++ b/spec/fixtures/api/schemas/graphql/packages/package_details.json @@ -13,7 +13,8 @@ "pipelines", "versions", "metadata", - "status" + "status", + "canDestroy" ], "properties": { "id": { @@ -31,6 +32,9 @@ "version": { "type": ["string", "null"] }, + "canDestroy": { + "type": ["boolean"] + }, "packageType": { "type": ["string"], "enum": [ @@ -50,6 +54,7 @@ "type": "object", "additionalProperties": false, "properties": { + "count": { "type": "integer" }, "pageInfo": { "type": "object" }, "edges": { "type": "array" }, "nodes": { "type": "array" } @@ -72,6 +77,7 @@ "type": "object", "additionalProperties": false, "properties": { + "count": { "type": "integer" }, "pageInfo": { "type": "object" }, "edges": { "type": "array" }, "nodes": { "type": "array" } @@ -91,6 +97,7 @@ "type": "object", "additionalProperties": false, "properties": { + "count": { "type": "integer" }, "pageInfo": { "type": "object" }, "edges": { "type": "array" }, "nodes": { "type": "array" } @@ -106,6 +113,7 @@ "properties": { "pageInfo": { "type": "object" }, "edges": { "type": "array" }, + "count": { "type": "integer" }, "nodes": { "type": "array", "items": { diff --git a/spec/fixtures/api/schemas/public_api/v4/environment.json b/spec/fixtures/api/schemas/public_api/v4/environment.json index b90bfe8de55..30104adaf5c 100644 --- a/spec/fixtures/api/schemas/public_api/v4/environment.json +++ b/spec/fixtures/api/schemas/public_api/v4/environment.json @@ -5,7 +5,9 @@ "name", "slug", "external_url", - "last_deployment" + "state", + "created_at", + "updated_at" ], "properties": { "id": { "type": "integer" }, @@ -19,6 +21,9 @@ ] }, "state": { "type": "string" }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" }, + "project": { "$ref": "project.json" }, "enable_advanced_logs_querying": { "type": "boolean" }, "logs_api_path": { "type": "string" }, "gitlab_managed_apps_logs_path": { "type": "string" } diff --git a/spec/fixtures/api/schemas/public_api/v4/service.json b/spec/fixtures/api/schemas/public_api/v4/integration.json index b6f13d1cfe7..b6f13d1cfe7 100644 --- a/spec/fixtures/api/schemas/public_api/v4/service.json +++ b/spec/fixtures/api/schemas/public_api/v4/integration.json diff --git a/spec/fixtures/api/schemas/public_api/v4/integrations.json b/spec/fixtures/api/schemas/public_api/v4/integrations.json new file mode 100644 index 00000000000..e7ebe7652c9 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/integrations.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "integration.json" } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/services.json b/spec/fixtures/api/schemas/public_api/v4/services.json deleted file mode 100644 index 78c59ecfa10..00000000000 --- a/spec/fixtures/api/schemas/public_api/v4/services.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "array", - "items": { "$ref": "service.json" } -} diff --git a/spec/fixtures/lib/gitlab/import_export/complex/project.json b/spec/fixtures/lib/gitlab/import_export/complex/project.json index 1072e63b20b..fd4c2d55124 100644 --- a/spec/fixtures/lib/gitlab/import_export/complex/project.json +++ b/spec/fixtures/lib/gitlab/import_export/complex/project.json @@ -7759,7 +7759,29 @@ "created_at": "2019-06-06T14:01:06.204Z", "updated_at": "2019-06-06T14:22:37.045Z", "name": "TestBoardABC", - "milestone_id": null, + "milestone": { + "id": 1, + "title": "test milestone", + "project_id": 8, + "description": "test milestone", + "due_date": null, + "created_at": "2016-06-14T15:02:04.415Z", + "updated_at": "2016-06-14T15:02:04.415Z", + "state": "active", + "iid": 1, + "events": [ + { + "id": 487, + "target_type": "Milestone", + "target_id": 1, + "project_id": 46, + "created_at": "2016-06-14T15:02:04.418Z", + "updated_at": "2016-06-14T15:02:04.418Z", + "action": 1, + "author_id": 18 + } + ] + }, "group_id": null, "weight": null, "lists": [ @@ -7772,7 +7794,29 @@ "created_at": "2019-06-06T14:01:06.214Z", "updated_at": "2019-06-06T14:01:06.214Z", "user_id": null, - "milestone_id": null + "milestone": { + "id": 1, + "title": "test milestone", + "project_id": 8, + "description": "test milestone", + "due_date": null, + "created_at": "2016-06-14T15:02:04.415Z", + "updated_at": "2016-06-14T15:02:04.415Z", + "state": "active", + "iid": 1, + "events": [ + { + "id": 487, + "target_type": "Milestone", + "target_id": 1, + "project_id": 46, + "created_at": "2016-06-14T15:02:04.418Z", + "updated_at": "2016-06-14T15:02:04.418Z", + "action": 1, + "author_id": 18 + } + ] + } }, { "id": 61, @@ -7783,7 +7827,29 @@ "created_at": "2019-06-06T14:01:43.197Z", "updated_at": "2019-06-06T14:01:43.197Z", "user_id": null, - "milestone_id": null, + "milestone": { + "id": 1, + "title": "test milestone", + "project_id": 8, + "description": "test milestone", + "due_date": null, + "created_at": "2016-06-14T15:02:04.415Z", + "updated_at": "2016-06-14T15:02:04.415Z", + "state": "active", + "iid": 1, + "events": [ + { + "id": 487, + "target_type": "Milestone", + "target_id": 1, + "project_id": 46, + "created_at": "2016-06-14T15:02:04.418Z", + "updated_at": "2016-06-14T15:02:04.418Z", + "action": 1, + "author_id": 18 + } + ] + }, "label": { "id": 20, "title": "testlabel", @@ -7807,7 +7873,29 @@ "created_at": "2019-06-06T14:01:06.221Z", "updated_at": "2019-06-06T14:01:06.221Z", "user_id": null, - "milestone_id": null + "milestone": { + "id": 1, + "title": "test milestone", + "project_id": 8, + "description": "test milestone", + "due_date": null, + "created_at": "2016-06-14T15:02:04.415Z", + "updated_at": "2016-06-14T15:02:04.415Z", + "state": "active", + "iid": 1, + "events": [ + { + "id": 487, + "target_type": "Milestone", + "target_id": 1, + "project_id": 46, + "created_at": "2016-06-14T15:02:04.418Z", + "updated_at": "2016-06-14T15:02:04.418Z", + "action": 1, + "author_id": 18 + } + ] + } } ] } diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project.json b/spec/fixtures/lib/gitlab/import_export/complex/tree/project.json index 203b0264f9e..2c5045ce806 100644 --- a/spec/fixtures/lib/gitlab/import_export/complex/tree/project.json +++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project.json @@ -1 +1,10 @@ -{"description":"Nisi et repellendus ut enim quo accusamus vel magnam.","import_type":"gitlab_project","creator_id":123,"visibility_level":10,"archived":false,"deploy_keys":[],"hooks":[]} +{ + "description": "Nisi et repellendus ut enim quo accusamus vel magnam.", + "import_type": "gitlab_project", + "creator_id": 123, + "visibility_level": 10, + "archived": false, + "deploy_keys": [], + "hooks": [], + "shared_runners_enabled": true +} diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/boards.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/boards.ndjson index ef18af69c9b..a63b583f087 100644 --- a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/boards.ndjson +++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/boards.ndjson @@ -1 +1 @@ -{"id":29,"project_id":49,"created_at":"2019-06-06T14:01:06.204Z","updated_at":"2019-06-06T14:22:37.045Z","name":"TestBoardABC","milestone_id":null,"group_id":null,"weight":null,"lists":[{"id":59,"board_id":29,"label_id":null,"list_type":"backlog","position":null,"created_at":"2019-06-06T14:01:06.214Z","updated_at":"2019-06-06T14:01:06.214Z","user_id":null,"milestone_id":null},{"id":61,"board_id":29,"label_id":20,"list_type":"label","position":0,"created_at":"2019-06-06T14:01:43.197Z","updated_at":"2019-06-06T14:01:43.197Z","user_id":null,"milestone_id":null,"label":{"id":20,"title":"testlabel","color":"#0033CC","project_id":49,"created_at":"2019-06-06T14:01:19.698Z","updated_at":"2019-06-06T14:01:19.698Z","template":false,"description":null,"group_id":null,"type":"ProjectLabel","priorities":[]}},{"id":60,"board_id":29,"label_id":null,"list_type":"closed","position":null,"created_at":"2019-06-06T14:01:06.221Z","updated_at":"2019-06-06T14:01:06.221Z","user_id":null,"milestone_id":null}]} +{"id":29,"project_id":49,"created_at":"2019-06-06T14:01:06.204Z","updated_at":"2019-06-06T14:22:37.045Z","name":"TestBoardABC","group_id":null,"weight":null,"milestone":{"id":1,"title":"test milestone","project_id":8,"description":"test milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"events":[{"id":487,"target_type":"Milestone","target_id":1,"project_id":46,"created_at":"2016-06-14T15:02:04.418Z","updated_at":"2016-06-14T15:02:04.418Z","action":1,"author_id":18}]},"lists":[{"id":59,"board_id":29,"label_id":null,"list_type":"backlog","position":null,"created_at":"2019-06-06T14:01:06.214Z","updated_at":"2019-06-06T14:01:06.214Z","user_id":null,"milestone":{"id":1,"title":"test milestone","project_id":8,"description":"test milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"events":[{"id":487,"target_type":"Milestone","target_id":1,"project_id":46,"created_at":"2016-06-14T15:02:04.418Z","updated_at":"2016-06-14T15:02:04.418Z","action":1,"author_id":18}]}},{"id":61,"board_id":29,"label_id":20,"list_type":"label","position":0,"created_at":"2019-06-06T14:01:43.197Z","updated_at":"2019-06-06T14:01:43.197Z","user_id":null,"milestone":{"id":1,"title":"test milestone","project_id":8,"description":"test milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"events":[{"id":487,"target_type":"Milestone","target_id":1,"project_id":46,"created_at":"2016-06-14T15:02:04.418Z","updated_at":"2016-06-14T15:02:04.418Z","action":1,"author_id":18}]},"label":{"id":20,"title":"testlabel","color":"#0033CC","project_id":49,"created_at":"2019-06-06T14:01:19.698Z","updated_at":"2019-06-06T14:01:19.698Z","template":false,"description":null,"group_id":null,"type":"ProjectLabel","priorities":[]}},{"id":60,"board_id":29,"label_id":null,"list_type":"closed","position":null,"created_at":"2019-06-06T14:01:06.221Z","updated_at":"2019-06-06T14:01:06.221Z","user_id":null,"milestone":{"id":1,"title":"test milestone","project_id":8,"description":"test milestone","due_date":null,"created_at":"2016-06-14T15:02:04.415Z","updated_at":"2016-06-14T15:02:04.415Z","state":"active","iid":1,"events":[{"id":487,"target_type":"Milestone","target_id":1,"project_id":46,"created_at":"2016-06-14T15:02:04.418Z","updated_at":"2016-06-14T15:02:04.418Z","action":1,"author_id":18}]}}]} diff --git a/spec/fixtures/lib/gitlab/performance_bar/peek_data.json b/spec/fixtures/lib/gitlab/performance_bar/peek_data.json index c60e787ddb1..69512c52cbd 100644 --- a/spec/fixtures/lib/gitlab/performance_bar/peek_data.json +++ b/spec/fixtures/lib/gitlab/performance_bar/peek_data.json @@ -64,9 +64,90 @@ "warnings": [] }, "gitaly": { - "duration": "0ms", - "calls": 0, - "details": [], + "duration": "30ms", + "calls": 2, + "details": [ + { + "start": 6301.575665897, + "feature": "commit_service#get_tree_entries", + "duration": 23.709, + "request": "{:repository=>\n {:storage_name=>\"nfs-file-cny01\",\n :relative_path=>\n \"@hashed/a6/80/a68072e80f075e89bc74a300101a9e71e8363bdb542182580162553462480a52.git\",\n :git_object_directory=>\"\",\n :git_alternate_object_directories=>[],\n :gl_repository=>\"project-278964\",\n :gl_project_path=>\"gitlab-org/gitlab\"},\n :revision=>\"master\",\n :path=>\".\",\n :sort=>:TREES_FIRST,\n :pagination_params=>{:page_token=>\"\", :limit=>100}}\n", + "rpc": "get_tree_entries", + "backtrace": [ + "lib/gitlab/gitaly_client/call.rb:48:in `block in instrument_stream'", + "lib/gitlab/gitaly_client/commit_service.rb:128:in `each'", + "lib/gitlab/gitaly_client/commit_service.rb:128:in `each'", + "lib/gitlab/gitaly_client/commit_service.rb:128:in `flat_map'", + "lib/gitlab/gitaly_client/commit_service.rb:128:in `tree_entries'", + "lib/gitlab/git/tree.rb:26:in `block in tree_entries'", + "lib/gitlab/git/wraps_gitaly_errors.rb:7:in `wrapped_gitaly_errors'", + "lib/gitlab/git/tree.rb:25:in `tree_entries'", + "lib/gitlab/git/rugged_impl/tree.rb:29:in `tree_entries'", + "lib/gitlab/git/tree.rb:21:in `where'", + "app/models/tree.rb:17:in `initialize'", + "app/models/repository.rb:681:in `new'", + "app/models/repository.rb:681:in `tree'", + "app/graphql/resolvers/paginated_tree_resolver.rb:35:in `resolve'", + "lib/gitlab/graphql/present/field_extension.rb:18:in `resolve'", + "lib/gitlab/graphql/extensions/externally_paginated_array_extension.rb:7:in `resolve'", + "lib/gitlab/graphql/generic_tracing.rb:40:in `with_labkit_tracing'", + "lib/gitlab/graphql/generic_tracing.rb:30:in `platform_trace'", + "lib/gitlab/graphql/generic_tracing.rb:40:in `with_labkit_tracing'", + "lib/gitlab/graphql/generic_tracing.rb:30:in `platform_trace'", + "lib/gitlab/graphql/generic_tracing.rb:40:in `with_labkit_tracing'", + "lib/gitlab/graphql/generic_tracing.rb:30:in `platform_trace'", + "app/graphql/gitlab_schema.rb:40:in `multiplex'", + "app/controllers/graphql_controller.rb:110:in `execute_multiplex'", + "app/controllers/graphql_controller.rb:41:in `execute'", + "ee/lib/gitlab/ip_address_state.rb:10:in `with'", + "ee/app/controllers/ee/application_controller.rb:44:in `set_current_ip_address'", + "app/controllers/application_controller.rb:497:in `set_current_admin'", + "lib/gitlab/session.rb:11:in `with_session'", + "app/controllers/application_controller.rb:488:in `set_session_storage'", + "app/controllers/application_controller.rb:482:in `set_locale'", + "app/controllers/application_controller.rb:476:in `set_current_context'", + "ee/lib/omni_auth/strategies/group_saml.rb:41:in `other_phase'", + "lib/gitlab/jira/middleware.rb:19:in `call'" + ], + "warnings": [] + }, { + "start": 9081.502219885, + "feature": "commit_service#find_commit", + "duration": 6.678, + "request": "{:repository=>\n {:storage_name=>\"nfs-file-cny01\",\n :relative_path=>\n \"@hashed/a6/80/a68072e80f075e89bc74a300101a9e71e8363bdb542182580162553462480a52.git\",\n :git_object_directory=>\"\",\n :git_alternate_object_directories=>[],\n :gl_repository=>\"project-278964\",\n :gl_project_path=>\"gitlab-org/gitlab\"},\n :revision=>\"master\"}\n", + "rpc": "find_commit", + "backtrace": [ + "lib/gitlab/gitaly_client/call.rb:30:in `call'", + "lib/gitlab/gitaly_client.rb:167:in `call'", + "lib/gitlab/gitaly_client/commit_service.rb:520:in `call_find_commit'", + "lib/gitlab/gitaly_client/commit_service.rb:354:in `find_commit'", + "lib/gitlab/git/commit.rb:74:in `block in find_commit'", + "lib/gitlab/git/wraps_gitaly_errors.rb:7:in `wrapped_gitaly_errors'", + "lib/gitlab/git/commit.rb:73:in `find_commit'", + "lib/gitlab/git/rugged_impl/commit.rb:41:in `find_commit'", + "lib/gitlab/git/commit.rb:65:in `find'", + "lib/gitlab/git/repository.rb:789:in `commit'", + "app/services/branches/diverging_commit_counts_service.rb:21:in `diverging_commit_counts'", + "app/services/branches/diverging_commit_counts_service.rb:11:in `call'", + "app/controllers/projects/branches_controller.rb:57:in `block (4 levels) in diverging_commit_counts'", + "app/controllers/projects/branches_controller.rb:57:in `to_h'", + "app/controllers/projects/branches_controller.rb:57:in `block (3 levels) in diverging_commit_counts'", + "lib/gitlab/gitaly_client.rb:325:in `allow_n_plus_1_calls'", + "app/controllers/projects/branches_controller.rb:56:in `block (2 levels) in diverging_commit_counts'", + "app/controllers/projects/branches_controller.rb:51:in `diverging_commit_counts'", + "ee/lib/gitlab/ip_address_state.rb:10:in `with'", + "ee/app/controllers/ee/application_controller.rb:44:in `set_current_ip_address'", + "app/controllers/application_controller.rb:497:in `set_current_admin'", + "lib/gitlab/session.rb:11:in `with_session'", + "app/controllers/application_controller.rb:488:in `set_session_storage'", + "app/controllers/application_controller.rb:482:in `set_locale'", + "app/controllers/application_controller.rb:476:in `set_current_context'", + "ee/lib/omni_auth/strategies/group_saml.rb:41:in `other_phase'", + "lib/gitlab/jira/middleware.rb:19:in `call'" + ], + "warnings": [] + } + ], "warnings": [] }, "redis": { diff --git a/spec/fixtures/markdown.md.erb b/spec/fixtures/markdown.md.erb index 100d17cc16e..2da16408fbc 100644 --- a/spec/fixtures/markdown.md.erb +++ b/spec/fixtures/markdown.md.erb @@ -52,6 +52,15 @@ Redcarpet supports this superscript syntax ( x^2 ). This (C<sub>6</sub>H<sub>12</sub>O<sub>6</sub>) is an example of subscripts in Markdown. +### Footnotes + +This is footnote 1.[^f1] + +A footnote with a `w` was failing.[^f2-w] + +[^f1]: Footnote 1 +[^f2-w]: Footnote with w + ### Next step After the Markdown has been turned into HTML, it gets passed through... diff --git a/spec/fixtures/ssl/letsencrypt_expired_x3.pem b/spec/fixtures/ssl/letsencrypt_expired_x3.pem new file mode 100644 index 00000000000..462df721ed7 --- /dev/null +++ b/spec/fixtures/ssl/letsencrypt_expired_x3.pem @@ -0,0 +1,98 @@ +-----BEGIN CERTIFICATE----- +MIIGJDCCBQygAwIBAgISBOSAE/WwQGsTbDJI1vDL9+eKMA0GCSqGSIb3DQEBCwUA +MDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQD +EwJSMzAeFw0yMTEwMDEyMjIxMTlaFw0yMTEyMzAyMjIxMThaMBkxFzAVBgNVBAMT +DndlYmRpb3hpZGUuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA +wf/TpE5AjzoLXMFQ+WHle7Dn5rlEe0bPee2JU386cZmMYnGFS5DR251FerSX28U4 +pqk2yS8oefHGi2PS6h8/MWxr+Zy/6hk3WkgwdIK3uPiUcfCdPV/btXDd4YqikEDm +BoOE4fQlqKQwtLOnhEZu9y8FQoxxoQ+7DndHrDixDoMbpUloxpqUZwziQnH4QHXE +32rQhq25+NUK/lVFGKOFnmZ2s/yUildKafqulHrLHOhumKMOEivzlFDZbtqP+RKt +nsrJ3i9O+nSQz6j5dv3Du6eaResrtK7tT1MFDNhcg2cgjNW64VLXQdFXYXE1OYsw +yAuXUnHNzWFhinyf80qeh2046YR21dlG8voIDQH4fGG5GmWLyu7glsWYVwQQ36VA +TTxPmAoaqUTl8A7cnlJpAo+BJ00mS/9DwJ7pkgGC7dYOhJzWlI7lPqzEfmJ+o8pj +CJlLIuqsn0vcCZQlmqCFMxK4asn+puLLnMjRLHIYEJKDNyPGHQEr2e5t4GUYZKaN +MEpXMwJd97tUamUKWeBPNIND/kOuqexe+okbOTRp34VAsK5oCpawEJckoNkK+sv0 +OrSWFOdfLBHv66p9qsrz8LQXxmN5JUBUe51SBSUo1Ul4/vGYdhuKd/8KcLw9/Al+ +HJN2hAeo3v+2fVey4hgGna7XNe8e3+E+OEQb4zpQDLkCAwEAAaOCAkswggJHMA4G +A1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYD +VR0TAQH/BAIwADAdBgNVHQ4EFgQU4PbvqCKatjx6GZMXy7v9GwykZq4wHwYDVR0j +BBgwFoAUFC6zF7dYVsuuUAlA5h+vnYsUwsYwVQYIKwYBBQUHAQEESTBHMCEGCCsG +AQUFBzABhhVodHRwOi8vcjMuby5sZW5jci5vcmcwIgYIKwYBBQUHMAKGFmh0dHA6 +Ly9yMy5pLmxlbmNyLm9yZy8wGQYDVR0RBBIwEIIOd2ViZGlveGlkZS5jb20wTAYD +VR0gBEUwQzAIBgZngQwBAgEwNwYLKwYBBAGC3xMBAQEwKDAmBggrBgEFBQcCARYa +aHR0cDovL2Nwcy5sZXRzZW5jcnlwdC5vcmcwggEGBgorBgEEAdZ5AgQCBIH3BIH0 +APIAdwBc3EOS/uarRUSxXprUVuYQN/vV+kfcoXOUsl7m9scOygAAAXw+KYGHAAAE +AwBIMEYCIQCqD6jMtHrGlE02Qh1FzFd4+qYzJTrChHmHBFIncPGQKAIhALeYk0Vf +/Lw2tX2beVlKN4/h1o8srNJv+06xkr1N6XmiAHcAfT7y+I//iFVoJMLAyp5SiXkr +xQ54CX8uapdomX4i8NcAAAF8PimBogAABAMASDBGAiEA0h883FFj1dSYKGym9+Wa +XgJRj526X7YlkhkZ5J1TjioCIQDyjMPrbo5liVi/e5b8gfDw5Fd9WNiTu1W1LKKu +UpE/qTANBgkqhkiG9w0BAQsFAAOCAQEAcx10nqp1kh2awwoqwf7Jo8Gycqx2bA2O +E2rveQ/BK9UhwvrNeEpE9SG6liMsYJKxGar0vbbBHvxzuMU00bhGjXFtUT5XuQ8q +FcU0OdycyZj8fjZmUNsJr82l8HvfJ50jfxFORTgj8Ln5MWVUFlbl0nD+06l28sDc +V+r/B4394fkoMsKXtiTA4/ZeOD1tHNsdxQ7sNQtEfqCG0wFCYHK3rs7XTZ1K0F3c +M051JShko1UKP/k5blrendOwVRwLtq+9pavGnJBeqNIVgugTER/IHlp4427WyhdY +KYjKoytW+XQyWqxU/Mh/O4rxkD8cZaE+FdZpP67VZ185AuZMbn+LcQ== +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw +WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg +RW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP +R5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx +sxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm +NHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg +Z3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG +/kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC +AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB +Af8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA +FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw +AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw +Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB +gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W +PTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl +ikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz +CkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm +lJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4 +avAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2 +yJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O +yK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids +hCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+ +HlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv +MldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX +nLRbwHOoq7hHwg== +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIFYDCCBEigAwIBAgIQQAF3ITfU6UK47naqPGQKtzANBgkqhkiG9w0BAQsFADA/ +MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT +DkRTVCBSb290IENBIFgzMB4XDTIxMDEyMDE5MTQwM1oXDTI0MDkzMDE4MTQwM1ow +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQCt6CRz9BQ385ueK1coHIe+3LffOJCMbjzmV6B493XC +ov71am72AE8o295ohmxEk7axY/0UEmu/H9LqMZshftEzPLpI9d1537O4/xLxIZpL +wYqGcWlKZmZsj348cL+tKSIG8+TA5oCu4kuPt5l+lAOf00eXfJlII1PoOK5PCm+D +LtFJV4yAdLbaL9A4jXsDcCEbdfIwPPqPrt3aY6vrFk/CjhFLfs8L6P+1dy70sntK +4EwSJQxwjQMpoOFTJOwT2e4ZvxCzSow/iaNhUd6shweU9GNx7C7ib1uYgeGJXDR5 +bHbvO5BieebbpJovJsXQEOEO3tkQjhb7t/eo98flAgeYjzYIlefiN5YNNnWe+w5y +sR2bvAP5SQXYgd0FtCrWQemsAXaVCg/Y39W9Eh81LygXbNKYwagJZHduRze6zqxZ +Xmidf3LWicUGQSk+WT7dJvUkyRGnWqNMQB9GoZm1pzpRboY7nn1ypxIFeFntPlF4 +FQsDj43QLwWyPntKHEtzBRL8xurgUBN8Q5N0s8p0544fAQjQMNRbcTa0B7rBMDBc +SLeCO5imfWCKoqMpgsy6vYMEG6KDA0Gh1gXxG8K28Kh8hjtGqEgqiNx2mna/H2ql +PRmP6zjzZN7IKw0KKP/32+IVQtQi0Cdd4Xn+GOdwiK1O5tmLOsbdJ1Fu/7xk9TND +TwIDAQABo4IBRjCCAUIwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw +SwYIKwYBBQUHAQEEPzA9MDsGCCsGAQUFBzAChi9odHRwOi8vYXBwcy5pZGVudHJ1 +c3QuY29tL3Jvb3RzL2RzdHJvb3RjYXgzLnA3YzAfBgNVHSMEGDAWgBTEp7Gkeyxx ++tvhS5B1/8QVYIWJEDBUBgNVHSAETTBLMAgGBmeBDAECATA/BgsrBgEEAYLfEwEB +ATAwMC4GCCsGAQUFBwIBFiJodHRwOi8vY3BzLnJvb3QteDEubGV0c2VuY3J5cHQu +b3JnMDwGA1UdHwQ1MDMwMaAvoC2GK2h0dHA6Ly9jcmwuaWRlbnRydXN0LmNvbS9E +U1RST09UQ0FYM0NSTC5jcmwwHQYDVR0OBBYEFHm0WeZ7tuXkAXOACIjIGlj26Ztu +MA0GCSqGSIb3DQEBCwUAA4IBAQAKcwBslm7/DlLQrt2M51oGrS+o44+/yQoDFVDC +5WxCu2+b9LRPwkSICHXM6webFGJueN7sJ7o5XPWioW5WlHAQU7G75K/QosMrAdSW +9MUgNTP52GE24HGNtLi1qoJFlcDyqSMo59ahy2cI2qBDLKobkx/J3vWraV0T9VuG +WCLKTVXkcGdtwlfFRjlBz4pYg1htmf5X6DYO8A4jqv2Il9DjXA6USbW1FzXSLr9O +he8Y4IWS6wY7bCkjCWDcRQJMEhg76fsO3txE+FiYruq9RUWhiF1myv4Q6W+CyBFC +Dfvp7OOGAN6dEOM4+qR9sdjoSYKEBpsr6GtPAQw4dy753ec5 +-----END CERTIFICATE----- diff --git a/spec/frontend/.eslintrc.yml b/spec/frontend/.eslintrc.yml index 145e6c8961a..e12c4e5e820 100644 --- a/spec/frontend/.eslintrc.yml +++ b/spec/frontend/.eslintrc.yml @@ -12,7 +12,6 @@ settings: jest: jestConfigFile: 'jest.config.js' globals: - getJSONFixture: false loadFixtures: false setFixtures: false rules: @@ -26,4 +25,9 @@ rules: - off "@gitlab/no-global-event-off": - off - + import/no-unresolved: + - error + # The test fixtures and graphql schema are dynamically generated in CI + # during the `frontend-fixtures` and `graphql-schema-dump` jobs. + # They may not be present during linting. + - ignore: ['^test_fixtures\/', 'tmp/tests/graphql/gitlab_schema.graphql'] diff --git a/spec/frontend/__helpers__/fixtures.js b/spec/frontend/__helpers__/fixtures.js index 4b86724df93..d8054d32fae 100644 --- a/spec/frontend/__helpers__/fixtures.js +++ b/spec/frontend/__helpers__/fixtures.js @@ -20,6 +20,11 @@ Did you run bin/rake frontend:fixtures?`, return fs.readFileSync(absolutePath, 'utf8'); } +/** + * @deprecated Use `import` to load a JSON fixture instead. + * See https://docs.gitlab.com/ee/development/testing_guide/frontend_testing.html#use-fixtures, + * https://gitlab.com/gitlab-org/gitlab/-/issues/339346. + */ export const getJSONFixture = (relativePath) => JSON.parse(getFixture(relativePath)); export const resetHTMLFixture = () => { diff --git a/spec/frontend/__helpers__/flush_promises.js b/spec/frontend/__helpers__/flush_promises.js new file mode 100644 index 00000000000..5287a060753 --- /dev/null +++ b/spec/frontend/__helpers__/flush_promises.js @@ -0,0 +1,3 @@ +export default function flushPromises() { + return new Promise(setImmediate); +} diff --git a/spec/frontend/access_tokens/components/projects_token_selector_spec.js b/spec/frontend/access_tokens/components/projects_token_selector_spec.js index 09f52fe9a5f..40aaf16d41f 100644 --- a/spec/frontend/access_tokens/components/projects_token_selector_spec.js +++ b/spec/frontend/access_tokens/components/projects_token_selector_spec.js @@ -11,7 +11,7 @@ import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { getJSONFixture } from 'helpers/fixtures'; +import getProjectsQueryResponse from 'test_fixtures/graphql/projects/access_tokens/get_projects.query.graphql.json'; import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -20,9 +20,6 @@ import getProjectsQuery from '~/access_tokens/graphql/queries/get_projects.query 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) => { diff --git a/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js b/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js index 2832de98769..e7a20ae114c 100644 --- a/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js +++ b/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js @@ -1,12 +1,12 @@ import { GlModal, GlSearchBoxByType } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import getDiffWithCommit from 'test_fixtures/merge_request_diffs/with_commit.json'; import AddReviewItemsModal from '~/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue'; import * as actions from '~/add_context_commits_modal/store/actions'; import mutations from '~/add_context_commits_modal/store/mutations'; import defaultState from '~/add_context_commits_modal/store/state'; -import getDiffWithCommit from '../../diffs/mock_data/diff_with_commit'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -18,7 +18,7 @@ describe('AddContextCommitsModal', () => { const removeContextCommits = jest.fn(); const resetModalState = jest.fn(); const searchCommits = jest.fn(); - const { commit } = getDiffWithCommit(); + const { commit } = getDiffWithCommit; const createWrapper = (props = {}) => { store = new Vuex.Store({ diff --git a/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js b/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js index 75f1cc41e23..85ecb4313c2 100644 --- a/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js +++ b/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js @@ -1,12 +1,12 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import getDiffWithCommit from 'test_fixtures/merge_request_diffs/with_commit.json'; import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue'; import CommitItem from '~/diffs/components/commit_item.vue'; -import getDiffWithCommit from '../../diffs/mock_data/diff_with_commit'; describe('ReviewTabContainer', () => { let wrapper; - const { commit } = getDiffWithCommit(); + const { commit } = getDiffWithCommit; const createWrapper = (props = {}) => { wrapper = shallowMount(ReviewTabContainer, { diff --git a/spec/frontend/add_context_commits_modal/store/mutations_spec.js b/spec/frontend/add_context_commits_modal/store/mutations_spec.js index 2331a4af1bc..7517c1c391e 100644 --- a/spec/frontend/add_context_commits_modal/store/mutations_spec.js +++ b/spec/frontend/add_context_commits_modal/store/mutations_spec.js @@ -1,10 +1,10 @@ +import getDiffWithCommit from 'test_fixtures/merge_request_diffs/with_commit.json'; import { TEST_HOST } from 'helpers/test_constants'; import * as types from '~/add_context_commits_modal/store/mutation_types'; import mutations from '~/add_context_commits_modal/store/mutations'; -import getDiffWithCommit from '../../diffs/mock_data/diff_with_commit'; describe('AddContextCommitsModalStoreMutations', () => { - const { commit } = getDiffWithCommit(); + const { commit } = getDiffWithCommit; describe('SET_BASE_CONFIG', () => { it('should set contextCommitsPath, mergeRequestIid and projectId', () => { const state = {}; diff --git a/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js index 4bb22feb913..5b4f954b672 100644 --- a/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js +++ b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js @@ -35,9 +35,6 @@ describe('Signup Form', () => { const findDenyListRawInputGroup = () => wrapper.findByTestId('domain-denylist-raw-input-group'); const findDenyListFileInputGroup = () => wrapper.findByTestId('domain-denylist-file-input-group'); - - const findRequireAdminApprovalCheckbox = () => - wrapper.findByTestId('require-admin-approval-checkbox'); const findUserCapInput = () => wrapper.findByTestId('user-cap-input'); const findModal = () => wrapper.find(GlModal); @@ -191,125 +188,6 @@ describe('Signup Form', () => { }); describe('form submit button confirmation modal for side-effect of adding possibly unwanted new users', () => { - it.each` - requireAdminApprovalAction | userCapAction | pendingUserCount | buttonEffect - ${'unchanged from true'} | ${'unchanged'} | ${0} | ${'submits form'} - ${'unchanged from false'} | ${'unchanged'} | ${0} | ${'submits form'} - ${'toggled off'} | ${'unchanged'} | ${1} | ${'shows confirmation modal'} - ${'toggled off'} | ${'unchanged'} | ${0} | ${'submits form'} - ${'toggled on'} | ${'unchanged'} | ${0} | ${'submits form'} - ${'unchanged from false'} | ${'increased'} | ${1} | ${'shows confirmation modal'} - ${'unchanged from true'} | ${'increased'} | ${0} | ${'submits form'} - ${'toggled off'} | ${'increased'} | ${1} | ${'shows confirmation modal'} - ${'toggled off'} | ${'increased'} | ${0} | ${'submits form'} - ${'toggled on'} | ${'increased'} | ${1} | ${'shows confirmation modal'} - ${'toggled on'} | ${'increased'} | ${0} | ${'submits form'} - ${'toggled on'} | ${'decreased'} | ${0} | ${'submits form'} - ${'toggled on'} | ${'decreased'} | ${1} | ${'submits form'} - ${'unchanged from false'} | ${'changed from limited to unlimited'} | ${1} | ${'shows confirmation modal'} - ${'unchanged from false'} | ${'changed from limited to unlimited'} | ${0} | ${'submits form'} - ${'unchanged from false'} | ${'changed from unlimited to limited'} | ${0} | ${'submits form'} - ${'unchanged from false'} | ${'unchanged from unlimited'} | ${0} | ${'submits form'} - `( - '$buttonEffect if require admin approval for new sign-ups is $requireAdminApprovalAction and the user cap is $userCapAction and pending user count is $pendingUserCount', - async ({ requireAdminApprovalAction, userCapAction, pendingUserCount, buttonEffect }) => { - let isModalDisplayed; - - switch (buttonEffect) { - case 'shows confirmation modal': - isModalDisplayed = true; - break; - case 'submits form': - isModalDisplayed = false; - break; - default: - isModalDisplayed = false; - break; - } - - const isFormSubmittedWhenClickingFormSubmitButton = !isModalDisplayed; - - const injectedProps = { - pendingUserCount, - }; - - const USER_CAP_DEFAULT = 5; - - switch (userCapAction) { - case 'changed from unlimited to limited': - injectedProps.newUserSignupsCap = ''; - break; - case 'unchanged from unlimited': - injectedProps.newUserSignupsCap = ''; - break; - default: - injectedProps.newUserSignupsCap = USER_CAP_DEFAULT; - break; - } - - switch (requireAdminApprovalAction) { - case 'unchanged from true': - injectedProps.requireAdminApprovalAfterUserSignup = true; - break; - case 'unchanged from false': - injectedProps.requireAdminApprovalAfterUserSignup = false; - break; - case 'toggled off': - injectedProps.requireAdminApprovalAfterUserSignup = true; - break; - case 'toggled on': - injectedProps.requireAdminApprovalAfterUserSignup = false; - break; - default: - injectedProps.requireAdminApprovalAfterUserSignup = false; - break; - } - - formSubmitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(); - - await mountComponent({ - injectedProps, - stubs: { GlButton, GlModal: stubComponent(GlModal) }, - }); - - findModal().vm.show = jest.fn(); - - if ( - requireAdminApprovalAction === 'toggled off' || - requireAdminApprovalAction === 'toggled on' - ) { - await findRequireAdminApprovalCheckbox().vm.$emit('input', false); - } - - switch (userCapAction) { - case 'increased': - await findUserCapInput().vm.$emit('input', USER_CAP_DEFAULT + 1); - break; - case 'decreased': - await findUserCapInput().vm.$emit('input', USER_CAP_DEFAULT - 1); - break; - case 'changed from limited to unlimited': - await findUserCapInput().vm.$emit('input', ''); - break; - case 'changed from unlimited to limited': - await findUserCapInput().vm.$emit('input', USER_CAP_DEFAULT); - break; - default: - break; - } - - await findFormSubmitButton().trigger('click'); - - if (isFormSubmittedWhenClickingFormSubmitButton) { - expect(formSubmitSpy).toHaveBeenCalled(); - expect(findModal().vm.show).not.toHaveBeenCalled(); - } else { - expect(formSubmitSpy).not.toHaveBeenCalled(); - expect(findModal().vm.show).toHaveBeenCalled(); - } - }, - ); - describe('modal actions', () => { beforeEach(async () => { const INITIAL_USER_CAP = 5; diff --git a/spec/frontend/admin/users/components/actions/actions_spec.js b/spec/frontend/admin/users/components/actions/actions_spec.js index fd05b08a3fb..67dcf5c6149 100644 --- a/spec/frontend/admin/users/components/actions/actions_spec.js +++ b/spec/frontend/admin/users/components/actions/actions_spec.js @@ -5,6 +5,7 @@ import { nextTick } from 'vue'; import Actions from '~/admin/users/components/actions'; import SharedDeleteAction from '~/admin/users/components/actions/shared/shared_delete_action.vue'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; import { CONFIRMATION_ACTIONS, DELETE_ACTIONS } from '../../constants'; import { paths } from '../../mock_data'; @@ -46,7 +47,10 @@ describe('Action components', () => { }); describe('DELETE_ACTION_COMPONENTS', () => { - const oncallSchedules = [{ name: 'schedule1' }, { name: 'schedule2' }]; + const userDeletionObstacles = [ + { name: 'schedule1', type: OBSTACLE_TYPES.oncallSchedules }, + { name: 'policy1', type: OBSTACLE_TYPES.escalationPolicies }, + ]; it.each(DELETE_ACTIONS.map((action) => [action, paths[action]]))( 'renders a dropdown item for "%s"', @@ -56,7 +60,7 @@ describe('Action components', () => { props: { username: 'John Doe', paths, - oncallSchedules, + userDeletionObstacles, }, stubs: { SharedDeleteAction }, }); @@ -69,8 +73,8 @@ describe('Action components', () => { expect(sharedAction.attributes('data-delete-user-url')).toBe(expectedPath); expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action)); expect(sharedAction.attributes('data-username')).toBe('John Doe'); - expect(sharedAction.attributes('data-oncall-schedules')).toBe( - JSON.stringify(oncallSchedules), + expect(sharedAction.attributes('data-user-deletion-obstacles')).toBe( + JSON.stringify(userDeletionObstacles), ); expect(findDropdownItem().exists()).toBe(true); }, diff --git a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap index 5e367891337..472158a9b10 100644 --- a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap +++ b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap @@ -8,8 +8,8 @@ exports[`User Operation confirmation modal renders modal with form included 1`] /> </p> - <oncall-schedules-list-stub - schedules="schedule1,schedule2" + <user-deletion-obstacles-list-stub + obstacles="schedule1,policy1" username="username" /> diff --git a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js index fee74764645..82307c9e3b3 100644 --- a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js +++ b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js @@ -1,7 +1,7 @@ import { GlButton, GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import DeleteUserModal from '~/admin/users/components/modals/delete_user_modal.vue'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; import ModalStub from './stubs/modal_stub'; const TEST_DELETE_USER_URL = 'delete-url'; @@ -25,7 +25,7 @@ describe('User Operation confirmation modal', () => { const getUsername = () => findUsernameInput().attributes('value'); const getMethodParam = () => new FormData(findForm().element).get('_method'); const getFormAction = () => findForm().attributes('action'); - const findOnCallSchedulesList = () => wrapper.findComponent(OncallSchedulesList); + const findUserDeletionObstaclesList = () => wrapper.findComponent(UserDeletionObstaclesList); const setUsername = (username) => { findUsernameInput().vm.$emit('input', username); @@ -33,7 +33,7 @@ describe('User Operation confirmation modal', () => { const username = 'username'; const badUsername = 'bad_username'; - const oncallSchedules = '["schedule1", "schedule2"]'; + const userDeletionObstacles = '["schedule1", "policy1"]'; const createComponent = (props = {}) => { wrapper = shallowMount(DeleteUserModal, { @@ -46,7 +46,7 @@ describe('User Operation confirmation modal', () => { deleteUserUrl: TEST_DELETE_USER_URL, blockUserUrl: TEST_BLOCK_USER_URL, csrfToken: TEST_CSRF, - oncallSchedules, + userDeletionObstacles, ...props, }, stubs: { @@ -150,18 +150,18 @@ describe('User Operation confirmation modal', () => { }); }); - describe('Related oncall-schedules list', () => { - it('does NOT render the list when user has no related schedules', () => { - createComponent({ oncallSchedules: '[]' }); - expect(findOnCallSchedulesList().exists()).toBe(false); + describe('Related user-deletion-obstacles list', () => { + it('does NOT render the list when user has no related obstacles', () => { + createComponent({ userDeletionObstacles: '[]' }); + expect(findUserDeletionObstaclesList().exists()).toBe(false); }); - it('renders the list when user has related schedules', () => { + it('renders the list when user has related obstalces', () => { createComponent(); - const schedules = findOnCallSchedulesList(); - expect(schedules.exists()).toBe(true); - expect(schedules.props('schedules')).toEqual(JSON.parse(oncallSchedules)); + const obstacles = findUserDeletionObstaclesList(); + expect(obstacles.exists()).toBe(true); + expect(obstacles.props('obstacles')).toEqual(JSON.parse(userDeletionObstacles)); }); }); }); diff --git a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap index ddb188edb10..f4d3fd97fd8 100644 --- a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap +++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap @@ -52,13 +52,13 @@ exports[`Alert integration settings form default state should match the default block="true" category="primary" clearalltext="Clear all" + clearalltextclass="gl-px-5" data-qa-selector="incident_templates_dropdown" headertext="" hideheaderborder="true" highlighteditemstitle="Selected" highlighteditemstitleclass="gl-px-5" id="alert-integration-settings-issue-template" - showhighlighteditemstitle="true" size="medium" text="selecte_tmpl" variant="default" diff --git a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js index 2537b8fb816..5d681c7da4f 100644 --- a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js +++ b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js @@ -1,6 +1,8 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; import { TEST_HOST } from 'helpers/test_constants'; +import waitForPromises from 'helpers/wait_for_promises'; import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue'; import getProjects from '~/analytics/shared/graphql/projects.query.graphql'; @@ -25,6 +27,17 @@ const projects = [ }, ]; +const MockGlDropdown = stubComponent(GlDropdown, { + template: ` + <div> + <div data-testid="vsa-highlighted-items"> + <slot name="highlighted-items"></slot> + </div> + <div data-testid="vsa-default-items"><slot></slot></div> + </div> + `, +}); + const defaultMocks = { $apollo: { query: jest.fn().mockResolvedValue({ @@ -38,22 +51,33 @@ let spyQuery; describe('ProjectsDropdownFilter component', () => { let wrapper; - const createComponent = (props = {}) => { + const createComponent = (props = {}, stubs = {}) => { spyQuery = defaultMocks.$apollo.query; - wrapper = mount(ProjectsDropdownFilter, { + wrapper = mountExtended(ProjectsDropdownFilter, { mocks: { ...defaultMocks }, propsData: { groupId: 1, groupNamespace: 'gitlab-org', ...props, }, + stubs, }); }; + const createWithMockDropdown = (props) => { + createComponent(props, { GlDropdown: MockGlDropdown }); + return waitForPromises(); + }; + afterEach(() => { wrapper.destroy(); }); + const findHighlightedItems = () => wrapper.findByTestId('vsa-highlighted-items'); + const findUnhighlightedItems = () => wrapper.findByTestId('vsa-default-items'); + const findHighlightedItemsTitle = () => wrapper.findByText('Selected'); + const findClearAllButton = () => wrapper.findByText('Clear all'); + const findDropdown = () => wrapper.find(GlDropdown); const findDropdownItems = () => @@ -75,8 +99,19 @@ describe('ProjectsDropdownFilter component', () => { const findDropdownFullPathAtIndex = (index) => findDropdownAtIndex(index).find('[data-testid="project-full-path"]'); - const selectDropdownItemAtIndex = (index) => + const selectDropdownItemAtIndex = (index) => { findDropdownAtIndex(index).find('button').trigger('click'); + return wrapper.vm.$nextTick(); + }; + + // NOTE: Selected items are now visually separated from unselected items + const findSelectedDropdownItems = () => findHighlightedItems().findAll(GlDropdownItem); + + const findSelectedDropdownAtIndex = (index) => findSelectedDropdownItems().at(index); + const findSelectedButtonIdentIconAtIndex = (index) => + findSelectedDropdownAtIndex(index).find('div.gl-avatar-identicon'); + const findSelectedButtonAvatarItemAtIndex = (index) => + findSelectedDropdownAtIndex(index).find('img.gl-avatar'); const selectedIds = () => wrapper.vm.selectedProjects.map(({ id }) => id); @@ -109,7 +144,80 @@ describe('ProjectsDropdownFilter component', () => { }); }); - describe('when passed a an array of defaultProject as prop', () => { + describe('highlighted items', () => { + const blockDefaultProps = { multiSelect: true }; + beforeEach(() => { + createComponent(blockDefaultProps); + }); + + describe('with no project selected', () => { + it('does not render the highlighted items', async () => { + await createWithMockDropdown(blockDefaultProps); + expect(findSelectedDropdownItems().length).toBe(0); + }); + + it('does not render the highlighted items title', () => { + expect(findHighlightedItemsTitle().exists()).toBe(false); + }); + + it('does not render the clear all button', () => { + expect(findClearAllButton().exists()).toBe(false); + }); + }); + + describe('with a selected project', () => { + beforeEach(async () => { + await selectDropdownItemAtIndex(0); + }); + + it('renders the highlighted items', async () => { + await createWithMockDropdown(blockDefaultProps); + await selectDropdownItemAtIndex(0); + + expect(findSelectedDropdownItems().length).toBe(1); + }); + + it('renders the highlighted items title', () => { + expect(findHighlightedItemsTitle().exists()).toBe(true); + }); + + it('renders the clear all button', () => { + expect(findClearAllButton().exists()).toBe(true); + }); + + it('clears all selected items when the clear all button is clicked', async () => { + await selectDropdownItemAtIndex(1); + + expect(wrapper.text()).toContain('2 projects selected'); + + findClearAllButton().trigger('click'); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).not.toContain('2 projects selected'); + expect(wrapper.text()).toContain('Select projects'); + }); + }); + }); + + describe('with a selected project and search term', () => { + beforeEach(async () => { + await createWithMockDropdown({ multiSelect: true }); + + selectDropdownItemAtIndex(0); + wrapper.setData({ searchTerm: 'this is a very long search string' }); + }); + + it('renders the highlighted items', async () => { + expect(findUnhighlightedItems().findAll('li').length).toBe(1); + }); + + it('hides the unhighlighted items that do not match the string', async () => { + expect(findUnhighlightedItems().findAll('li').length).toBe(1); + expect(findUnhighlightedItems().text()).toContain('No matching results'); + }); + }); + + describe('when passed an array of defaultProject as prop', () => { beforeEach(() => { createComponent({ defaultProjects: [projects[0]], @@ -130,8 +238,9 @@ describe('ProjectsDropdownFilter component', () => { }); describe('when multiSelect is false', () => { + const blockDefaultProps = { multiSelect: false }; beforeEach(() => { - createComponent({ multiSelect: false }); + createComponent(blockDefaultProps); }); describe('displays the correct information', () => { @@ -183,21 +292,19 @@ describe('ProjectsDropdownFilter component', () => { }); it('renders an avatar in the dropdown button when the project has an avatarUrl', async () => { - selectDropdownItemAtIndex(0); + await createWithMockDropdown(blockDefaultProps); + await selectDropdownItemAtIndex(0); - await wrapper.vm.$nextTick().then(() => { - expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true); - expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false); - }); + expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(true); + expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(false); }); it("renders an identicon in the dropdown button when the project doesn't have an avatarUrl", async () => { - selectDropdownItemAtIndex(1); + await createWithMockDropdown(blockDefaultProps); + await selectDropdownItemAtIndex(1); - await wrapper.vm.$nextTick().then(() => { - expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false); - expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true); - }); + expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(false); + expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/analytics/shared/utils_spec.js b/spec/frontend/analytics/shared/utils_spec.js index e3293f2d8bd..0513ccb2890 100644 --- a/spec/frontend/analytics/shared/utils_spec.js +++ b/spec/frontend/analytics/shared/utils_spec.js @@ -1,4 +1,10 @@ -import { filterBySearchTerm } from '~/analytics/shared/utils'; +import { + filterBySearchTerm, + extractFilterQueryParameters, + extractPaginationQueryParameters, + getDataZoomOption, +} from '~/analytics/shared/utils'; +import { objectToQuery } from '~/lib/utils/url_utility'; describe('filterBySearchTerm', () => { const data = [ @@ -22,3 +28,151 @@ describe('filterBySearchTerm', () => { expect(filterBySearchTerm(data, 'ne', 'title')).toEqual([data[0]]); }); }); + +describe('extractFilterQueryParameters', () => { + const selectedAuthor = 'Author 1'; + const selectedMilestone = 'Milestone 1.0'; + const selectedSourceBranch = 'main'; + const selectedTargetBranch = 'feature-1'; + const selectedAssigneeList = ['Alice', 'Bob']; + const selectedLabelList = ['Label 1', 'Label 2']; + + const queryParamsString = objectToQuery({ + source_branch_name: selectedSourceBranch, + target_branch_name: selectedTargetBranch, + author_username: selectedAuthor, + milestone_title: selectedMilestone, + assignee_username: selectedAssigneeList, + label_name: selectedLabelList, + }); + + it('extracts the correct filter parameters from a url', () => { + const result = extractFilterQueryParameters(queryParamsString); + const operator = '='; + const expectedFilters = { + selectedAssigneeList: { operator, value: selectedAssigneeList.join(',') }, + selectedLabelList: { operator, value: selectedLabelList.join(',') }, + selectedAuthor: { operator, value: selectedAuthor }, + selectedMilestone: { operator, value: selectedMilestone }, + selectedSourceBranch: { operator, value: selectedSourceBranch }, + selectedTargetBranch: { operator, value: selectedTargetBranch }, + }; + expect(result).toMatchObject(expectedFilters); + }); + + it('returns null for missing parameters', () => { + const result = extractFilterQueryParameters(''); + const expectedFilters = { + selectedAuthor: null, + selectedMilestone: null, + selectedSourceBranch: null, + selectedTargetBranch: null, + }; + expect(result).toMatchObject(expectedFilters); + }); + + it('only returns the parameters we expect', () => { + const result = extractFilterQueryParameters('foo="one"&bar="two"'); + const resultKeys = Object.keys(result); + ['foo', 'bar'].forEach((key) => { + expect(resultKeys).not.toContain(key); + }); + + [ + 'selectedAuthor', + 'selectedMilestone', + 'selectedSourceBranch', + 'selectedTargetBranch', + 'selectedAssigneeList', + 'selectedLabelList', + ].forEach((key) => { + expect(resultKeys).toContain(key); + }); + }); + + it('returns an empty array for missing list parameters', () => { + const result = extractFilterQueryParameters(''); + const expectedFilters = { selectedAssigneeList: [], selectedLabelList: [] }; + expect(result).toMatchObject(expectedFilters); + }); +}); + +describe('extractPaginationQueryParameters', () => { + const sort = 'title'; + const direction = 'asc'; + const page = '1'; + const queryParamsString = objectToQuery({ sort, direction, page }); + + it('extracts the correct filter parameters from a url', () => { + const result = extractPaginationQueryParameters(queryParamsString); + const expectedFilters = { sort, page, direction }; + expect(result).toMatchObject(expectedFilters); + }); + + it('returns null for missing parameters', () => { + const result = extractPaginationQueryParameters(''); + const expectedFilters = { sort: null, direction: null, page: null }; + expect(result).toMatchObject(expectedFilters); + }); + + it('only returns the parameters we expect', () => { + const result = extractPaginationQueryParameters('foo="one"&bar="two"&qux="three"'); + const resultKeys = Object.keys(result); + ['foo', 'bar', 'qux'].forEach((key) => { + expect(resultKeys).not.toContain(key); + }); + + ['sort', 'page', 'direction'].forEach((key) => { + expect(resultKeys).toContain(key); + }); + }); +}); + +describe('getDataZoomOption', () => { + it('returns an empty object when totalItems <= maxItemsPerPage', () => { + const totalItems = 10; + const maxItemsPerPage = 20; + + expect(getDataZoomOption({ totalItems, maxItemsPerPage })).toEqual({}); + }); + + describe('when totalItems > maxItemsPerPage', () => { + const totalItems = 30; + const maxItemsPerPage = 20; + + it('properly computes the end interval for the default datazoom config', () => { + const expected = [ + { + type: 'slider', + bottom: 10, + start: 0, + end: 67, + }, + ]; + + expect(getDataZoomOption({ totalItems, maxItemsPerPage })).toEqual(expected); + }); + + it('properly computes the end interval for a custom datazoom config', () => { + const dataZoom = [ + { type: 'slider', bottom: 0, start: 0 }, + { type: 'inside', start: 0 }, + ]; + const expected = [ + { + type: 'slider', + bottom: 0, + start: 0, + end: 67, + }, + { + type: 'inside', + start: 0, + end: 67, + }, + ]; + + expect(getDataZoomOption({ totalItems, maxItemsPerPage, dataZoom })).toEqual(expected); + }); + }); +}); diff --git a/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js b/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js index 61c6a1dd167..870375318e3 100644 --- a/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js +++ b/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js @@ -1,5 +1,5 @@ -import { GlForm } from '@gitlab/ui'; import { within } from '@testing-library/dom'; +import { GlForm } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import ManageTwoFactorForm, { diff --git a/spec/frontend/blob/file_template_mediator_spec.js b/spec/frontend/blob/file_template_mediator_spec.js new file mode 100644 index 00000000000..44e12deb564 --- /dev/null +++ b/spec/frontend/blob/file_template_mediator_spec.js @@ -0,0 +1,53 @@ +import TemplateSelectorMediator from '~/blob/file_template_mediator'; + +describe('Template Selector Mediator', () => { + let mediator; + + describe('setFilename', () => { + let input; + const newFileName = 'foo'; + const editor = jest.fn().mockImplementationOnce(() => ({ + getValue: jest.fn().mockImplementation(() => {}), + }))(); + + beforeEach(() => { + setFixtures('<div class="file-editor"><input class="js-file-path-name-input" /></div>'); + input = document.querySelector('.js-file-path-name-input'); + mediator = new TemplateSelectorMediator({ + editor, + currentAction: jest.fn(), + projectId: jest.fn(), + }); + }); + + it('fills out the input field', () => { + expect(input.value).toBe(''); + mediator.setFilename(newFileName); + expect(input.value).toBe(newFileName); + }); + + it.each` + name | newName | shouldDispatch + ${newFileName} | ${newFileName} | ${false} + ${newFileName} | ${''} | ${true} + ${newFileName} | ${undefined} | ${false} + ${''} | ${''} | ${false} + ${''} | ${newFileName} | ${true} + ${''} | ${undefined} | ${false} + `( + 'correctly reacts to the name change when current name is $name and newName is $newName', + ({ name, newName, shouldDispatch }) => { + input.value = name; + const eventHandler = jest.fn(); + input.addEventListener('change', eventHandler); + + mediator.setFilename(newName); + if (shouldDispatch) { + expect(eventHandler).toHaveBeenCalledTimes(1); + } else { + expect(eventHandler).not.toHaveBeenCalled(); + } + }, + ); + }); +}); diff --git a/spec/frontend/boards/components/board_add_new_column_trigger_spec.js b/spec/frontend/boards/components/board_add_new_column_trigger_spec.js new file mode 100644 index 00000000000..c35f2463f69 --- /dev/null +++ b/spec/frontend/boards/components/board_add_new_column_trigger_spec.js @@ -0,0 +1,59 @@ +import { GlButton } from '@gitlab/ui'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue'; +import { createStore } from '~/boards/stores'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +Vue.use(Vuex); + +describe('BoardAddNewColumnTrigger', () => { + let wrapper; + + const findBoardsCreateList = () => wrapper.findByTestId('boards-create-list'); + const findTooltipText = () => getBinding(findBoardsCreateList().element, 'gl-tooltip'); + + const mountComponent = () => { + wrapper = mountExtended(BoardAddNewColumnTrigger, { + directives: { + GlTooltip: createMockDirective(), + }, + store: createStore(), + }); + }; + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when button is active', () => { + it('does not show the tooltip', () => { + const tooltip = findTooltipText(); + + expect(tooltip.value).toBe(''); + }); + + it('renders an enabled button', () => { + const button = wrapper.find(GlButton); + + expect(button.props('disabled')).toBe(false); + }); + }); + + describe('when button is disabled', () => { + it('shows the tooltip', async () => { + wrapper.find(GlButton).vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + const tooltip = findTooltipText(); + + expect(tooltip.value).toBe('The list creation wizard is already open'); + }); + }); +}); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 62e0fa7a68a..0b90912a584 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -21,9 +21,10 @@ import { getMoveData, updateListPosition, } from '~/boards/boards_util'; +import { gqlClient } from '~/boards/graphql'; import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql'; import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'; -import actions, { gqlClient } from '~/boards/stores/actions'; +import actions from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; import mutations from '~/boards/stores/mutations'; @@ -1331,20 +1332,54 @@ describe('addListItem', () => { list: mockLists[0], item: mockIssue, position: 0, + inProgress: true, }; - testAction(actions.addListItem, payload, {}, [ - { - type: types.ADD_BOARD_ITEM_TO_LIST, - payload: { - listId: mockLists[0].id, - itemId: mockIssue.id, - atIndex: 0, - inProgress: false, + testAction( + actions.addListItem, + payload, + {}, + [ + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { + listId: mockLists[0].id, + itemId: mockIssue.id, + atIndex: 0, + inProgress: true, + }, }, - }, - { type: types.UPDATE_BOARD_ITEM, payload: mockIssue }, - ]); + { type: types.UPDATE_BOARD_ITEM, payload: mockIssue }, + ], + [], + ); + }); + + it('should commit ADD_BOARD_ITEM_TO_LIST and UPDATE_BOARD_ITEM mutations, dispatch setActiveId action when inProgress is false', () => { + const payload = { + list: mockLists[0], + item: mockIssue, + position: 0, + }; + + testAction( + actions.addListItem, + payload, + {}, + [ + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { + listId: mockLists[0].id, + itemId: mockIssue.id, + atIndex: 0, + inProgress: false, + }, + }, + { type: types.UPDATE_BOARD_ITEM, payload: mockIssue }, + ], + [{ type: 'setActiveId', payload: { id: mockIssue.id, sidebarType: ISSUABLE } }], + ); }); }); @@ -1542,7 +1577,7 @@ describe('setActiveIssueLabels', () => { projectPath: 'h/b', }; - it('should assign labels on success', (done) => { + it('should assign labels on success, and sets loading state for labels', (done) => { jest .spyOn(gqlClient, 'mutate') .mockResolvedValue({ data: { updateIssue: { issue: { labels: { nodes: labels } } } } }); @@ -1559,6 +1594,14 @@ describe('setActiveIssueLabels', () => { { ...state, ...getters }, [ { + type: types.SET_LABELS_LOADING, + payload: true, + }, + { + type: types.SET_LABELS_LOADING, + payload: false, + }, + { type: types.UPDATE_BOARD_ITEM_BY_ID, payload, }, diff --git a/spec/frontend/clusters/agents/components/show_spec.js b/spec/frontend/clusters/agents/components/show_spec.js new file mode 100644 index 00000000000..fd04ff8b3e7 --- /dev/null +++ b/spec/frontend/clusters/agents/components/show_spec.js @@ -0,0 +1,195 @@ +import { GlAlert, GlKeysetPagination, GlLoadingIcon, GlSprintf, GlTab } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import ClusterAgentShow from '~/clusters/agents/components/show.vue'; +import TokenTable from '~/clusters/agents/components/token_table.vue'; +import getAgentQuery from '~/clusters/agents/graphql/queries/get_cluster_agent.query.graphql'; +import { useFakeDate } from 'helpers/fake_date'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('ClusterAgentShow', () => { + let wrapper; + useFakeDate([2021, 2, 15]); + + const propsData = { + agentName: 'cluster-agent', + projectPath: 'path/to/project', + }; + + const defaultClusterAgent = { + id: '1', + createdAt: '2021-02-13T00:00:00Z', + createdByUser: { + name: 'user-1', + }, + name: 'token-1', + tokens: { + count: 1, + nodes: [], + pageInfo: null, + }, + }; + + const createWrapper = ({ clusterAgent, queryResponse = null }) => { + const agentQueryResponse = + queryResponse || jest.fn().mockResolvedValue({ data: { project: { clusterAgent } } }); + const apolloProvider = createMockApollo([[getAgentQuery, agentQueryResponse]]); + + wrapper = shallowMount(ClusterAgentShow, { + localVue, + apolloProvider, + propsData, + stubs: { GlSprintf, TimeAgoTooltip, GlTab }, + }); + }; + + const createWrapperWithoutApollo = ({ clusterAgent, loading = false }) => { + const $apollo = { queries: { clusterAgent: { loading } } }; + + wrapper = shallowMount(ClusterAgentShow, { + propsData, + mocks: { $apollo, clusterAgent }, + stubs: { GlTab }, + }); + }; + + const findCreatedText = () => wrapper.find('[data-testid="cluster-agent-create-info"]').text(); + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findPaginationButtons = () => wrapper.find(GlKeysetPagination); + const findTokenCount = () => wrapper.find('[data-testid="cluster-agent-token-count"]').text(); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default behaviour', () => { + beforeEach(() => { + return createWrapper({ clusterAgent: defaultClusterAgent }); + }); + + it('displays the agent name', () => { + expect(wrapper.text()).toContain(propsData.agentName); + }); + + it('displays agent create information', () => { + expect(findCreatedText()).toMatchInterpolatedText('Created by user-1 2 days ago'); + }); + + it('displays token count', () => { + expect(findTokenCount()).toMatchInterpolatedText( + `${ClusterAgentShow.i18n.tokens} ${defaultClusterAgent.tokens.count}`, + ); + }); + + it('renders token table', () => { + expect(wrapper.find(TokenTable).exists()).toBe(true); + }); + + it('should not render pagination buttons when there are no additional pages', () => { + expect(findPaginationButtons().exists()).toBe(false); + }); + }); + + describe('when create user is unknown', () => { + const missingUser = { + ...defaultClusterAgent, + createdByUser: null, + }; + + beforeEach(() => { + return createWrapper({ clusterAgent: missingUser }); + }); + + it('displays agent create information with unknown user', () => { + expect(findCreatedText()).toMatchInterpolatedText('Created by Unknown user 2 days ago'); + }); + }); + + describe('when token count is missing', () => { + const missingTokens = { + ...defaultClusterAgent, + tokens: null, + }; + + beforeEach(() => { + return createWrapper({ clusterAgent: missingTokens }); + }); + + it('displays token header with no count', () => { + expect(findTokenCount()).toMatchInterpolatedText(`${ClusterAgentShow.i18n.tokens}`); + }); + }); + + describe('when the token list has additional pages', () => { + const pageInfo = { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'prev', + endCursor: 'next', + }; + + const tokenPagination = { + ...defaultClusterAgent, + tokens: { + ...defaultClusterAgent.tokens, + pageInfo, + }, + }; + + beforeEach(() => { + return createWrapper({ clusterAgent: tokenPagination }); + }); + + it('should render pagination buttons', () => { + expect(findPaginationButtons().exists()).toBe(true); + }); + + it('should pass pageInfo to the pagination component', () => { + expect(findPaginationButtons().props()).toMatchObject(pageInfo); + }); + }); + + describe('when the agent query is loading', () => { + describe('when the clusterAgent is missing', () => { + beforeEach(() => { + return createWrapper({ + clusterAgent: null, + queryResponse: jest.fn().mockReturnValue(new Promise(() => {})), + }); + }); + + it('displays a loading icon and hides the token tab', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(wrapper.text()).not.toContain(ClusterAgentShow.i18n.tokens); + }); + }); + + describe('when the clusterAgent is present', () => { + beforeEach(() => { + createWrapperWithoutApollo({ clusterAgent: defaultClusterAgent, loading: true }); + }); + + it('displays a loading icon and token tab', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(wrapper.text()).toContain(ClusterAgentShow.i18n.tokens); + }); + }); + }); + + describe('when the agent query has errored', () => { + beforeEach(() => { + createWrapper({ clusterAgent: null, queryResponse: jest.fn().mockRejectedValue() }); + return waitForPromises(); + }); + + it('displays an alert message', () => { + expect(wrapper.find(GlAlert).exists()).toBe(true); + expect(wrapper.text()).toContain(ClusterAgentShow.i18n.loadingError); + }); + }); +}); diff --git a/spec/frontend/clusters/agents/components/token_table_spec.js b/spec/frontend/clusters/agents/components/token_table_spec.js new file mode 100644 index 00000000000..47ff944dd84 --- /dev/null +++ b/spec/frontend/clusters/agents/components/token_table_spec.js @@ -0,0 +1,135 @@ +import { GlEmptyState, GlLink, GlTooltip, GlTruncate } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import TokenTable from '~/clusters/agents/components/token_table.vue'; +import { useFakeDate } from 'helpers/fake_date'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; + +describe('ClusterAgentTokenTable', () => { + let wrapper; + useFakeDate([2021, 2, 15]); + + const defaultTokens = [ + { + id: '1', + createdAt: '2021-02-13T00:00:00Z', + description: 'Description of token 1', + createdByUser: { + name: 'user-1', + }, + lastUsedAt: '2021-02-13T00:00:00Z', + name: 'token-1', + }, + { + id: '2', + createdAt: '2021-02-10T00:00:00Z', + description: null, + createdByUser: null, + lastUsedAt: null, + name: 'token-2', + }, + ]; + + const createComponent = (tokens) => { + wrapper = extendedWrapper(mount(TokenTable, { propsData: { tokens } })); + }; + + const findEmptyState = () => wrapper.find(GlEmptyState); + const findLink = () => wrapper.find(GlLink); + + beforeEach(() => { + return createComponent(defaultTokens); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays a learn more link', () => { + const learnMoreLink = findLink(); + + expect(learnMoreLink.exists()).toBe(true); + expect(learnMoreLink.text()).toBe(TokenTable.i18n.learnMore); + }); + + it.each` + name | lineNumber + ${'token-1'} | ${0} + ${'token-2'} | ${1} + `('displays token name "$name" for line "$lineNumber"', ({ name, lineNumber }) => { + const tokens = wrapper.findAll('[data-testid="agent-token-name"]'); + const token = tokens.at(lineNumber); + + expect(token.text()).toBe(name); + }); + + it.each` + lastContactText | lineNumber + ${'2 days ago'} | ${0} + ${'Never'} | ${1} + `( + 'displays last contact information "$lastContactText" for line "$lineNumber"', + ({ lastContactText, lineNumber }) => { + const tokens = wrapper.findAllByTestId('agent-token-used'); + const token = tokens.at(lineNumber); + + expect(token.text()).toBe(lastContactText); + }, + ); + + it.each` + createdText | lineNumber + ${'2 days ago'} | ${0} + ${'5 days ago'} | ${1} + `( + 'displays created information "$createdText" for line "$lineNumber"', + ({ createdText, lineNumber }) => { + const tokens = wrapper.findAll('[data-testid="agent-token-created-time"]'); + const token = tokens.at(lineNumber); + + expect(token.text()).toBe(createdText); + }, + ); + + it.each` + createdBy | lineNumber + ${'user-1'} | ${0} + ${'Unknown user'} | ${1} + `( + 'displays creator information "$createdBy" for line "$lineNumber"', + ({ createdBy, lineNumber }) => { + const tokens = wrapper.findAll('[data-testid="agent-token-created-user"]'); + const token = tokens.at(lineNumber); + + expect(token.text()).toBe(createdBy); + }, + ); + + it.each` + description | truncatesText | hasTooltip | lineNumber + ${'Description of token 1'} | ${true} | ${true} | ${0} + ${''} | ${false} | ${false} | ${1} + `( + 'displays description information "$description" for line "$lineNumber"', + ({ description, truncatesText, hasTooltip, lineNumber }) => { + const tokens = wrapper.findAll('[data-testid="agent-token-description"]'); + const token = tokens.at(lineNumber); + + expect(token.text()).toContain(description); + expect(token.find(GlTruncate).exists()).toBe(truncatesText); + expect(token.find(GlTooltip).exists()).toBe(hasTooltip); + }, + ); + + describe('when there are no tokens', () => { + beforeEach(() => { + return createComponent([]); + }); + + it('displays an empty state', () => { + const emptyState = findEmptyState(); + + expect(emptyState.exists()).toBe(true); + expect(emptyState.text()).toContain(TokenTable.i18n.noTokens); + }); + }); +}); diff --git a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap index b34265b7234..42d81900911 100644 --- a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap @@ -33,7 +33,7 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ <span class="sr-only" > - Toggle Dropdown + Toggle dropdown </span> </button> <ul @@ -46,21 +46,7 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ > <!----> - <div - class="gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5" - > - <div - class="gl-display-flex" - > - <!----> - </div> - - <div - class="gl-display-flex" - > - <!----> - </div> - </div> + <!----> <div class="gl-new-dropdown-contents" diff --git a/spec/frontend/clusters_list/components/agent_empty_state_spec.js b/spec/frontend/clusters_list/components/agent_empty_state_spec.js new file mode 100644 index 00000000000..a548721588e --- /dev/null +++ b/spec/frontend/clusters_list/components/agent_empty_state_spec.js @@ -0,0 +1,77 @@ +import { GlAlert, GlEmptyState, GlSprintf } from '@gitlab/ui'; +import AgentEmptyState from '~/clusters_list/components/agent_empty_state.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +const emptyStateImage = '/path/to/image'; +const projectPath = 'path/to/project'; +const agentDocsUrl = 'path/to/agentDocs'; +const installDocsUrl = 'path/to/installDocs'; +const getStartedDocsUrl = 'path/to/getStartedDocs'; +const integrationDocsUrl = 'path/to/integrationDocs'; + +describe('AgentEmptyStateComponent', () => { + let wrapper; + + const propsData = { + hasConfigurations: false, + }; + const provideData = { + emptyStateImage, + projectPath, + agentDocsUrl, + installDocsUrl, + getStartedDocsUrl, + integrationDocsUrl, + }; + + const findConfigurationsAlert = () => wrapper.findComponent(GlAlert); + const findAgentDocsLink = () => wrapper.findByTestId('agent-docs-link'); + const findInstallDocsLink = () => wrapper.findByTestId('install-docs-link'); + const findIntegrationButton = () => wrapper.findByTestId('integration-primary-button'); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + beforeEach(() => { + wrapper = shallowMountExtended(AgentEmptyState, { + propsData, + provide: provideData, + stubs: { GlEmptyState, GlSprintf }, + }); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + it('renders correct href attributes for the links', () => { + expect(findAgentDocsLink().attributes('href')).toBe(agentDocsUrl); + expect(findInstallDocsLink().attributes('href')).toBe(installDocsUrl); + }); + + describe('when there are no agent configurations in repository', () => { + it('should render notification message box', () => { + expect(findConfigurationsAlert().exists()).toBe(true); + }); + + it('should disable integration button', () => { + expect(findIntegrationButton().attributes('disabled')).toBe('true'); + }); + }); + + describe('when there is a list of agent configurations', () => { + beforeEach(() => { + propsData.hasConfigurations = true; + wrapper = shallowMountExtended(AgentEmptyState, { + propsData, + provide: provideData, + }); + }); + it('should render content without notification message box', () => { + expect(findEmptyState().exists()).toBe(true); + expect(findConfigurationsAlert().exists()).toBe(false); + expect(findIntegrationButton().attributes('disabled')).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js new file mode 100644 index 00000000000..e3b90584f29 --- /dev/null +++ b/spec/frontend/clusters_list/components/agent_table_spec.js @@ -0,0 +1,117 @@ +import { GlButton, GlLink, GlIcon } from '@gitlab/ui'; +import AgentTable from '~/clusters_list/components/agent_table.vue'; +import { ACTIVE_CONNECTION_TIME } from '~/clusters_list/constants'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +const connectedTimeNow = new Date(); +const connectedTimeInactive = new Date(connectedTimeNow.getTime() - ACTIVE_CONNECTION_TIME); + +const propsData = { + agents: [ + { + name: 'agent-1', + configFolder: { + webPath: '/agent/full/path', + }, + webPath: '/agent-1', + status: 'unused', + lastContact: null, + tokens: null, + }, + { + name: 'agent-2', + webPath: '/agent-2', + status: 'active', + lastContact: connectedTimeNow.getTime(), + tokens: { + nodes: [ + { + lastUsedAt: connectedTimeNow, + }, + ], + }, + }, + { + name: 'agent-3', + webPath: '/agent-3', + status: 'inactive', + lastContact: connectedTimeInactive.getTime(), + tokens: { + nodes: [ + { + lastUsedAt: connectedTimeInactive, + }, + ], + }, + }, + ], +}; +const provideData = { integrationDocsUrl: 'path/to/integrationDocs' }; + +describe('AgentTable', () => { + let wrapper; + + const findAgentLink = (at) => wrapper.findAllByTestId('cluster-agent-name-link').at(at); + const findStatusIcon = (at) => wrapper.findAllComponents(GlIcon).at(at); + const findStatusText = (at) => wrapper.findAllByTestId('cluster-agent-connection-status').at(at); + const findLastContactText = (at) => wrapper.findAllByTestId('cluster-agent-last-contact').at(at); + const findConfiguration = (at) => + wrapper.findAllByTestId('cluster-agent-configuration-link').at(at); + + beforeEach(() => { + wrapper = mountExtended(AgentTable, { propsData, provide: provideData }); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + it('displays header button', () => { + expect(wrapper.find(GlButton).text()).toBe('Install a new GitLab Agent'); + }); + + describe('agent table', () => { + it.each` + agentName | link | lineNumber + ${'agent-1'} | ${'/agent-1'} | ${0} + ${'agent-2'} | ${'/agent-2'} | ${1} + `('displays agent link', ({ agentName, link, lineNumber }) => { + expect(findAgentLink(lineNumber).text()).toBe(agentName); + expect(findAgentLink(lineNumber).attributes('href')).toBe(link); + }); + + it.each` + status | iconName | lineNumber + ${'Never connected'} | ${'status-neutral'} | ${0} + ${'Connected'} | ${'status-success'} | ${1} + ${'Not connected'} | ${'severity-critical'} | ${2} + `('displays agent connection status', ({ status, iconName, lineNumber }) => { + expect(findStatusText(lineNumber).text()).toBe(status); + expect(findStatusIcon(lineNumber).props('name')).toBe(iconName); + }); + + it.each` + lastContact | lineNumber + ${'Never'} | ${0} + ${timeagoMixin.methods.timeFormatted(connectedTimeNow)} | ${1} + ${timeagoMixin.methods.timeFormatted(connectedTimeInactive)} | ${2} + `('displays agent last contact time', ({ lastContact, lineNumber }) => { + expect(findLastContactText(lineNumber).text()).toBe(lastContact); + }); + + it.each` + agentPath | hasLink | lineNumber + ${'.gitlab/agents/agent-1'} | ${true} | ${0} + ${'.gitlab/agents/agent-2'} | ${false} | ${1} + `('displays config file path', ({ agentPath, hasLink, lineNumber }) => { + const findLink = findConfiguration(lineNumber).find(GlLink); + + expect(findLink.exists()).toBe(hasLink); + expect(findConfiguration(lineNumber).text()).toBe(agentPath); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/agents_spec.js b/spec/frontend/clusters_list/components/agents_spec.js new file mode 100644 index 00000000000..54d5ae94172 --- /dev/null +++ b/spec/frontend/clusters_list/components/agents_spec.js @@ -0,0 +1,246 @@ +import { GlAlert, GlKeysetPagination, GlLoadingIcon } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import AgentEmptyState from '~/clusters_list/components/agent_empty_state.vue'; +import AgentTable from '~/clusters_list/components/agent_table.vue'; +import Agents from '~/clusters_list/components/agents.vue'; +import { ACTIVE_CONNECTION_TIME } from '~/clusters_list/constants'; +import getAgentsQuery from '~/clusters_list/graphql/queries/get_agents.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('Agents', () => { + let wrapper; + + const propsData = { + defaultBranchName: 'default', + }; + const provideData = { + projectPath: 'path/to/project', + kasAddress: 'kas.example.com', + }; + + const createWrapper = ({ agents = [], pageInfo = null, trees = [] }) => { + const provide = provideData; + const apolloQueryResponse = { + data: { + project: { + clusterAgents: { nodes: agents, pageInfo, tokens: { nodes: [] } }, + repository: { tree: { trees: { nodes: trees, pageInfo } } }, + }, + }, + }; + + const apolloProvider = createMockApollo([ + [getAgentsQuery, jest.fn().mockResolvedValue(apolloQueryResponse, provide)], + ]); + + wrapper = shallowMount(Agents, { + localVue, + apolloProvider, + propsData, + provide: provideData, + }); + + return wrapper.vm.$nextTick(); + }; + + const findAgentTable = () => wrapper.find(AgentTable); + const findEmptyState = () => wrapper.find(AgentEmptyState); + const findPaginationButtons = () => wrapper.find(GlKeysetPagination); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('when there is a list of agents', () => { + let testDate = new Date(); + const agents = [ + { + id: '1', + name: 'agent-1', + webPath: '/agent-1', + tokens: null, + }, + { + id: '2', + name: 'agent-2', + webPath: '/agent-2', + tokens: { + nodes: [ + { + lastUsedAt: testDate, + }, + ], + }, + }, + ]; + + const trees = [ + { + name: 'agent-2', + path: '.gitlab/agents/agent-2', + webPath: '/project/path/.gitlab/agents/agent-2', + }, + ]; + + const expectedAgentsList = [ + { + id: '1', + name: 'agent-1', + webPath: '/agent-1', + configFolder: undefined, + status: 'unused', + lastContact: null, + tokens: null, + }, + { + id: '2', + name: 'agent-2', + configFolder: { + name: 'agent-2', + path: '.gitlab/agents/agent-2', + webPath: '/project/path/.gitlab/agents/agent-2', + }, + webPath: '/agent-2', + status: 'active', + lastContact: new Date(testDate).getTime(), + tokens: { + nodes: [ + { + lastUsedAt: testDate, + }, + ], + }, + }, + ]; + + beforeEach(() => { + return createWrapper({ agents, trees }); + }); + + it('should render agent table', () => { + expect(findAgentTable().exists()).toBe(true); + expect(findEmptyState().exists()).toBe(false); + }); + + it('should pass agent and folder info to table component', () => { + expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList); + }); + + describe('when the agent has recently connected tokens', () => { + it('should set agent status to active', () => { + expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList); + }); + }); + + describe('when the agent has tokens connected more then 8 minutes ago', () => { + const now = new Date(); + testDate = new Date(now.getTime() - ACTIVE_CONNECTION_TIME); + it('should set agent status to inactive', () => { + expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList); + }); + }); + + describe('when the agent has no connected tokens', () => { + testDate = null; + it('should set agent status to unused', () => { + expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList); + }); + }); + + it('should not render pagination buttons when there are no additional pages', () => { + expect(findPaginationButtons().exists()).toBe(false); + }); + + describe('when the list has additional pages', () => { + const pageInfo = { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'prev', + endCursor: 'next', + }; + + beforeEach(() => { + return createWrapper({ + agents, + pageInfo, + }); + }); + + it('should render pagination buttons', () => { + expect(findPaginationButtons().exists()).toBe(true); + }); + + it('should pass pageInfo to the pagination component', () => { + expect(findPaginationButtons().props()).toMatchObject(pageInfo); + }); + }); + }); + + describe('when the agent list is empty', () => { + beforeEach(() => { + return createWrapper({ agents: [] }); + }); + + it('should render empty state', () => { + expect(findAgentTable().exists()).toBe(false); + expect(findEmptyState().exists()).toBe(true); + }); + }); + + describe('when the agent configurations are present', () => { + const trees = [ + { + name: 'agent-1', + path: '.gitlab/agents/agent-1', + webPath: '/project/path/.gitlab/agents/agent-1', + }, + ]; + + beforeEach(() => { + return createWrapper({ agents: [], trees }); + }); + + it('should pass the correct hasConfigurations boolean value to empty state component', () => { + expect(findEmptyState().props('hasConfigurations')).toEqual(true); + }); + }); + + describe('when agents query has errored', () => { + beforeEach(() => { + return createWrapper({ agents: null }); + }); + + it('displays an alert message', () => { + expect(wrapper.find(GlAlert).exists()).toBe(true); + }); + }); + + describe('when agents query is loading', () => { + const mocks = { + $apollo: { + queries: { + agents: { + loading: true, + }, + }, + }, + }; + + beforeEach(() => { + wrapper = shallowMount(Agents, { mocks, propsData, provide: provideData }); + + return wrapper.vm.$nextTick(); + }); + + it('displays a loading icon', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js new file mode 100644 index 00000000000..40c2c59e187 --- /dev/null +++ b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js @@ -0,0 +1,129 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { createLocalVue, mount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue'; +import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '~/clusters_list/constants'; +import agentConfigurationsQuery from '~/clusters_list/graphql/queries/agent_configurations.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { agentConfigurationsResponse } from './mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('AvailableAgentsDropdown', () => { + let wrapper; + + const i18n = I18N_AVAILABLE_AGENTS_DROPDOWN; + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findConfiguredAgentItem = () => findDropdownItems().at(0); + + const createWrapper = ({ propsData = {}, isLoading = false }) => { + const provide = { + projectPath: 'path/to/project', + }; + + wrapper = (() => { + if (isLoading) { + const mocks = { + $apollo: { + queries: { + agents: { + loading: true, + }, + }, + }, + }; + + return mount(AvailableAgentsDropdown, { mocks, provide, propsData }); + } + + const apolloProvider = createMockApollo([ + [agentConfigurationsQuery, jest.fn().mockResolvedValue(agentConfigurationsResponse)], + ]); + + return mount(AvailableAgentsDropdown, { + localVue, + apolloProvider, + provide, + propsData, + }); + })(); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('there are agents available', () => { + const propsData = { + isRegistering: false, + }; + + beforeEach(() => { + createWrapper({ propsData }); + }); + + it('prompts to select an agent', () => { + expect(findDropdown().props('text')).toBe(i18n.selectAgent); + }); + + it('shows only agents that are not yet installed', () => { + expect(findDropdownItems()).toHaveLength(1); + expect(findConfiguredAgentItem().text()).toBe('configured-agent'); + expect(findConfiguredAgentItem().props('isChecked')).toBe(false); + }); + + describe('click events', () => { + beforeEach(() => { + findConfiguredAgentItem().vm.$emit('click'); + }); + + it('emits agentSelected with the name of the clicked agent', () => { + expect(wrapper.emitted('agentSelected')).toEqual([['configured-agent']]); + }); + + it('marks the clicked item as selected', () => { + expect(findDropdown().props('text')).toBe('configured-agent'); + expect(findConfiguredAgentItem().props('isChecked')).toBe(true); + }); + }); + }); + + describe('registration in progress', () => { + const propsData = { + isRegistering: true, + }; + + beforeEach(() => { + createWrapper({ propsData }); + }); + + it('updates the text in the dropdown', () => { + expect(findDropdown().props('text')).toBe(i18n.registeringAgent); + }); + + it('displays a loading icon', () => { + expect(findDropdown().props('loading')).toBe(true); + }); + }); + + describe('agents query is loading', () => { + const propsData = { + isRegistering: false, + }; + + beforeEach(() => { + createWrapper({ propsData, isLoading: true }); + }); + + it('updates the text in the dropdown', () => { + expect(findDropdown().text()).toBe(i18n.selectAgent); + }); + + it('displays a loading icon', () => { + expect(findDropdown().props('loading')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/install_agent_modal_spec.js b/spec/frontend/clusters_list/components/install_agent_modal_spec.js new file mode 100644 index 00000000000..98ca5e05b3f --- /dev/null +++ b/spec/frontend/clusters_list/components/install_agent_modal_spec.js @@ -0,0 +1,190 @@ +import { GlAlert, GlButton, GlFormInputGroup } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue'; +import InstallAgentModal from '~/clusters_list/components/install_agent_modal.vue'; +import { I18N_INSTALL_AGENT_MODAL } from '~/clusters_list/constants'; +import createAgentMutation from '~/clusters_list/graphql/mutations/create_agent.mutation.graphql'; +import createAgentTokenMutation from '~/clusters_list/graphql/mutations/create_agent_token.mutation.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import CodeBlock from '~/vue_shared/components/code_block.vue'; +import { + createAgentResponse, + createAgentErrorResponse, + createAgentTokenResponse, + createAgentTokenErrorResponse, +} from '../mocks/apollo'; +import ModalStub from '../stubs'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('InstallAgentModal', () => { + let wrapper; + let apolloProvider; + + const i18n = I18N_INSTALL_AGENT_MODAL; + const findModal = () => wrapper.findComponent(ModalStub); + const findAgentDropdown = () => findModal().findComponent(AvailableAgentsDropdown); + const findAlert = () => findModal().findComponent(GlAlert); + const findButtonByVariant = (variant) => + findModal() + .findAll(GlButton) + .wrappers.find((button) => button.props('variant') === variant); + const findActionButton = () => findButtonByVariant('confirm'); + const findCancelButton = () => findButtonByVariant('default'); + + const expectDisabledAttribute = (element, disabled) => { + if (disabled) { + expect(element.attributes('disabled')).toBe('true'); + } else { + expect(element.attributes('disabled')).toBeUndefined(); + } + }; + + const createWrapper = () => { + const provide = { + projectPath: 'path/to/project', + kasAddress: 'kas.example.com', + }; + + wrapper = shallowMount(InstallAgentModal, { + attachTo: document.body, + stubs: { + GlModal: ModalStub, + }, + localVue, + apolloProvider, + provide, + }); + }; + + const mockSelectedAgentResponse = () => { + createWrapper(); + + wrapper.vm.setAgentName('agent-name'); + findActionButton().vm.$emit('click'); + + return waitForPromises(); + }; + + beforeEach(() => { + createWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + apolloProvider = null; + }); + + describe('initial state', () => { + it('renders the dropdown for available agents', () => { + expect(findAgentDropdown().isVisible()).toBe(true); + expect(findModal().text()).not.toContain(i18n.basicInstallTitle); + expect(findModal().findComponent(GlFormInputGroup).exists()).toBe(false); + expect(findModal().findComponent(GlAlert).exists()).toBe(false); + expect(findModal().findComponent(CodeBlock).exists()).toBe(false); + }); + + it('renders a cancel button', () => { + expect(findCancelButton().isVisible()).toBe(true); + expectDisabledAttribute(findCancelButton(), false); + }); + + it('renders a disabled next button', () => { + expect(findActionButton().isVisible()).toBe(true); + expect(findActionButton().text()).toBe(i18n.next); + expectDisabledAttribute(findActionButton(), true); + }); + }); + + describe('an agent is selected', () => { + beforeEach(() => { + findAgentDropdown().vm.$emit('agentSelected'); + }); + + it('enables the next button', () => { + expect(findActionButton().isVisible()).toBe(true); + expectDisabledAttribute(findActionButton(), false); + }); + }); + + describe('registering an agent', () => { + const createAgentHandler = jest.fn().mockResolvedValue(createAgentResponse); + const createAgentTokenHandler = jest.fn().mockResolvedValue(createAgentTokenResponse); + + beforeEach(() => { + apolloProvider = createMockApollo([ + [createAgentMutation, createAgentHandler], + [createAgentTokenMutation, createAgentTokenHandler], + ]); + + return mockSelectedAgentResponse(apolloProvider); + }); + + it('creates an agent and token', () => { + expect(createAgentHandler).toHaveBeenCalledWith({ + input: { name: 'agent-name', projectPath: 'path/to/project' }, + }); + + expect(createAgentTokenHandler).toHaveBeenCalledWith({ + input: { clusterAgentId: 'agent-id', name: 'agent-name' }, + }); + }); + + it('renders a done button', () => { + expect(findActionButton().isVisible()).toBe(true); + expect(findActionButton().text()).toBe(i18n.done); + expectDisabledAttribute(findActionButton(), false); + }); + + it('shows agent instructions', () => { + const modalText = findModal().text(); + expect(modalText).toContain(i18n.basicInstallTitle); + expect(modalText).toContain(i18n.basicInstallBody); + + const token = findModal().findComponent(GlFormInputGroup); + expect(token.props('value')).toBe('mock-agent-token'); + + const alert = findModal().findComponent(GlAlert); + expect(alert.props('title')).toBe(i18n.tokenSingleUseWarningTitle); + + const code = findModal().findComponent(CodeBlock).props('code'); + expect(code).toContain('--agent-token=mock-agent-token'); + expect(code).toContain('--kas-address=kas.example.com'); + }); + + describe('error creating agent', () => { + beforeEach(() => { + apolloProvider = createMockApollo([ + [createAgentMutation, jest.fn().mockResolvedValue(createAgentErrorResponse)], + ]); + + return mockSelectedAgentResponse(); + }); + + it('displays the error message', () => { + expect(findAlert().text()).toBe(createAgentErrorResponse.data.createClusterAgent.errors[0]); + }); + }); + + describe('error creating token', () => { + beforeEach(() => { + apolloProvider = createMockApollo([ + [createAgentMutation, jest.fn().mockResolvedValue(createAgentResponse)], + [createAgentTokenMutation, jest.fn().mockResolvedValue(createAgentTokenErrorResponse)], + ]); + + return mockSelectedAgentResponse(); + }); + + it('displays the error message', () => { + expect(findAlert().text()).toBe( + createAgentTokenErrorResponse.data.clusterAgentTokenCreate.errors[0], + ); + }); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/mock_data.js b/spec/frontend/clusters_list/components/mock_data.js new file mode 100644 index 00000000000..e388d791b89 --- /dev/null +++ b/spec/frontend/clusters_list/components/mock_data.js @@ -0,0 +1,12 @@ +export const agentConfigurationsResponse = { + data: { + project: { + agentConfigurations: { + nodes: [{ agentName: 'installed-agent' }, { agentName: 'configured-agent' }], + }, + clusterAgents: { + nodes: [{ name: 'installed-agent' }], + }, + }, + }, +}; diff --git a/spec/frontend/clusters_list/mocks/apollo.js b/spec/frontend/clusters_list/mocks/apollo.js new file mode 100644 index 00000000000..27b71a0d4b5 --- /dev/null +++ b/spec/frontend/clusters_list/mocks/apollo.js @@ -0,0 +1,45 @@ +export const createAgentResponse = { + data: { + createClusterAgent: { + clusterAgent: { + id: 'agent-id', + }, + errors: [], + }, + }, +}; + +export const createAgentErrorResponse = { + data: { + createClusterAgent: { + clusterAgent: { + id: 'agent-id', + }, + errors: ['could not create agent'], + }, + }, +}; + +export const createAgentTokenResponse = { + data: { + clusterAgentTokenCreate: { + token: { + id: 'token-id', + }, + secret: 'mock-agent-token', + errors: [], + }, + }, +}; + +export const createAgentTokenErrorResponse = { + data: { + clusterAgentTokenCreate: { + token: { + id: 'token-id', + }, + secret: 'mock-agent-token', + errors: ['could not create agent token'], + }, + }, +}; diff --git a/spec/frontend/clusters_list/stubs.js b/spec/frontend/clusters_list/stubs.js new file mode 100644 index 00000000000..5769d6190f6 --- /dev/null +++ b/spec/frontend/clusters_list/stubs.js @@ -0,0 +1,14 @@ +const ModalStub = { + name: 'glmodal-stub', + template: ` + <div> + <slot></slot> + <slot name="modal-footer"></slot> + </div> + `, + methods: { + hide: jest.fn(), + }, +}; + +export default ModalStub; diff --git a/spec/frontend/comment_type_toggle_spec.js b/spec/frontend/comment_type_toggle_spec.js deleted file mode 100644 index 06dbfac1803..00000000000 --- a/spec/frontend/comment_type_toggle_spec.js +++ /dev/null @@ -1,169 +0,0 @@ -import CommentTypeToggle from '~/comment_type_toggle'; -import DropLab from '~/droplab/drop_lab'; -import InputSetter from '~/droplab/plugins/input_setter'; - -describe('CommentTypeToggle', () => { - const testContext = {}; - - describe('class constructor', () => { - beforeEach(() => { - testContext.dropdownTrigger = {}; - testContext.dropdownList = {}; - testContext.noteTypeInput = {}; - testContext.submitButton = {}; - testContext.closeButton = {}; - - testContext.commentTypeToggle = new CommentTypeToggle({ - dropdownTrigger: testContext.dropdownTrigger, - dropdownList: testContext.dropdownList, - noteTypeInput: testContext.noteTypeInput, - submitButton: testContext.submitButton, - closeButton: testContext.closeButton, - }); - }); - - it('should set .dropdownTrigger', () => { - expect(testContext.commentTypeToggle.dropdownTrigger).toBe(testContext.dropdownTrigger); - }); - - it('should set .dropdownList', () => { - expect(testContext.commentTypeToggle.dropdownList).toBe(testContext.dropdownList); - }); - - it('should set .noteTypeInput', () => { - expect(testContext.commentTypeToggle.noteTypeInput).toBe(testContext.noteTypeInput); - }); - - it('should set .submitButton', () => { - expect(testContext.commentTypeToggle.submitButton).toBe(testContext.submitButton); - }); - - it('should set .closeButton', () => { - expect(testContext.commentTypeToggle.closeButton).toBe(testContext.closeButton); - }); - - it('should set .reopenButton', () => { - expect(testContext.commentTypeToggle.reopenButton).toBe(testContext.reopenButton); - }); - }); - - describe('initDroplab', () => { - beforeEach(() => { - testContext.commentTypeToggle = { - dropdownTrigger: {}, - dropdownList: {}, - noteTypeInput: {}, - submitButton: {}, - closeButton: {}, - setConfig: () => {}, - }; - testContext.config = {}; - - jest.spyOn(DropLab.prototype, 'init').mockImplementation(); - jest.spyOn(DropLab.prototype, 'constructor').mockImplementation(); - - jest.spyOn(testContext.commentTypeToggle, 'setConfig').mockReturnValue(testContext.config); - - CommentTypeToggle.prototype.initDroplab.call(testContext.commentTypeToggle); - }); - - it('should instantiate a DropLab instance and set .droplab', () => { - expect(testContext.commentTypeToggle.droplab instanceof DropLab).toBe(true); - }); - - it('should call .setConfig', () => { - expect(testContext.commentTypeToggle.setConfig).toHaveBeenCalled(); - }); - - it('should call DropLab.prototype.init', () => { - expect(DropLab.prototype.init).toHaveBeenCalledWith( - testContext.commentTypeToggle.dropdownTrigger, - testContext.commentTypeToggle.dropdownList, - [InputSetter], - testContext.config, - ); - }); - }); - - describe('setConfig', () => { - describe('if no .closeButton is provided', () => { - beforeEach(() => { - testContext.commentTypeToggle = { - dropdownTrigger: {}, - dropdownList: {}, - noteTypeInput: {}, - submitButton: {}, - reopenButton: {}, - }; - - testContext.setConfig = CommentTypeToggle.prototype.setConfig.call( - testContext.commentTypeToggle, - ); - }); - - it('should not add .closeButton related InputSetter config', () => { - expect(testContext.setConfig).toEqual({ - InputSetter: [ - { - input: testContext.commentTypeToggle.noteTypeInput, - valueAttribute: 'data-value', - }, - { - input: testContext.commentTypeToggle.submitButton, - valueAttribute: 'data-submit-text', - }, - { - input: testContext.commentTypeToggle.reopenButton, - valueAttribute: 'data-reopen-text', - }, - { - input: testContext.commentTypeToggle.reopenButton, - valueAttribute: 'data-reopen-text', - inputAttribute: 'data-alternative-text', - }, - ], - }); - }); - }); - - describe('if no .reopenButton is provided', () => { - beforeEach(() => { - testContext.commentTypeToggle = { - dropdownTrigger: {}, - dropdownList: {}, - noteTypeInput: {}, - submitButton: {}, - closeButton: {}, - }; - - testContext.setConfig = CommentTypeToggle.prototype.setConfig.call( - testContext.commentTypeToggle, - ); - }); - - it('should not add .reopenButton related InputSetter config', () => { - expect(testContext.setConfig).toEqual({ - InputSetter: [ - { - input: testContext.commentTypeToggle.noteTypeInput, - valueAttribute: 'data-value', - }, - { - input: testContext.commentTypeToggle.submitButton, - valueAttribute: 'data-submit-text', - }, - { - input: testContext.commentTypeToggle.closeButton, - valueAttribute: 'data-close-text', - }, - { - input: testContext.commentTypeToggle.closeButton, - valueAttribute: 'data-close-text', - inputAttribute: 'data-alternative-text', - }, - ], - }); - }); - }); - }); -}); diff --git a/spec/frontend/commit/commit_pipeline_status_component_spec.js b/spec/frontend/commit/commit_pipeline_status_component_spec.js index 8082b8524e7..3a549e66eb7 100644 --- a/spec/frontend/commit/commit_pipeline_status_component_spec.js +++ b/spec/frontend/commit/commit_pipeline_status_component_spec.js @@ -1,7 +1,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Visibility from 'visibilityjs'; -import { getJSONFixture } from 'helpers/fixtures'; +import fixture from 'test_fixtures/pipelines/pipelines.json'; import createFlash from '~/flash'; import Poll from '~/lib/utils/poll'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; @@ -20,7 +20,7 @@ jest.mock('~/projects/tree/services/commit_pipeline_service', () => describe('Commit pipeline status component', () => { let wrapper; - const { pipelines } = getJSONFixture('pipelines/pipelines.json'); + const { pipelines } = fixture; const { status: mockCiStatus } = pipelines[0].details; const defaultProps = { diff --git a/spec/frontend/commit/pipelines/pipelines_table_spec.js b/spec/frontend/commit/pipelines/pipelines_table_spec.js index 1defb3d586c..17f7be9d1d7 100644 --- a/spec/frontend/commit/pipelines/pipelines_table_spec.js +++ b/spec/frontend/commit/pipelines/pipelines_table_spec.js @@ -1,6 +1,7 @@ import { GlEmptyState, GlLoadingIcon, GlModal, GlTable } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import fixture from 'test_fixtures/pipelines/pipelines.json'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import Api from '~/api'; @@ -8,7 +9,6 @@ import PipelinesTable from '~/commit/pipelines/pipelines_table.vue'; import axios from '~/lib/utils/axios_utils'; describe('Pipelines table in Commits and Merge requests', () => { - const jsonFixtureName = 'pipelines/pipelines.json'; let wrapper; let pipeline; let mock; @@ -37,7 +37,7 @@ describe('Pipelines table in Commits and Merge requests', () => { beforeEach(() => { mock = new MockAdapter(axios); - const { pipelines } = getJSONFixture(jsonFixtureName); + const { pipelines } = fixture; pipeline = pipelines.find((p) => p.user !== null && p.commit !== null); }); diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap index 8f5516545eb..178c7d749c8 100644 --- a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap +++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap @@ -11,14 +11,7 @@ exports[`content_editor/components/toolbar_link_button renders dropdown componen <ul role=\\"menu\\" tabindex=\\"-1\\" class=\\"dropdown-menu\\"> <div class=\\"gl-new-dropdown-inner\\"> <!----> - <div class=\\"gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5\\"> - <div class=\\"gl-display-flex\\"> - <!----> - </div> - <div class=\\"gl-display-flex\\"> - <!----> - </div> - </div> + <!----> <div class=\\"gl-new-dropdown-contents\\"> <!----> <li role=\\"presentation\\" class=\\"gl-px-3!\\"> diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js index a5df3d73289..ec58877470c 100644 --- a/spec/frontend/content_editor/components/top_toolbar_spec.js +++ b/spec/frontend/content_editor/components/top_toolbar_spec.js @@ -31,6 +31,7 @@ describe('content_editor/components/top_toolbar', () => { ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }} ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }} ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }} + ${'details'} | ${{ contentType: 'details', iconName: 'details-block', label: 'Add a collapsible section', editorCommand: 'toggleDetails' }} ${'horizontal-rule'} | ${{ contentType: 'horizontalRule', iconName: 'dash', label: 'Add a horizontal rule', editorCommand: 'setHorizontalRule' }} ${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }} ${'text-styles'} | ${{}} diff --git a/spec/frontend/content_editor/components/wrappers/details_spec.js b/spec/frontend/content_editor/components/wrappers/details_spec.js new file mode 100644 index 00000000000..d746b9fa2f1 --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/details_spec.js @@ -0,0 +1,40 @@ +import { NodeViewContent } from '@tiptap/vue-2'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import DetailsWrapper from '~/content_editor/components/wrappers/details.vue'; + +describe('content/components/wrappers/details', () => { + let wrapper; + + const createWrapper = async () => { + wrapper = shallowMountExtended(DetailsWrapper, { + propsData: { + node: {}, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a node-view-content as a ul element', () => { + createWrapper(); + + expect(wrapper.findComponent(NodeViewContent).props().as).toBe('ul'); + }); + + it('is "open" by default', () => { + createWrapper(); + + expect(wrapper.findByTestId('details-toggle-icon').classes()).toContain('is-open'); + expect(wrapper.findComponent(NodeViewContent).classes()).toContain('is-open'); + }); + + it('closes the details block on clicking the details toggle icon', async () => { + createWrapper(); + + await wrapper.findByTestId('details-toggle-icon').trigger('click'); + expect(wrapper.findByTestId('details-toggle-icon').classes()).not.toContain('is-open'); + expect(wrapper.findComponent(NodeViewContent).classes()).not.toContain('is-open'); + }); +}); diff --git a/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js b/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js new file mode 100644 index 00000000000..de8f8efd260 --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js @@ -0,0 +1,43 @@ +import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; +import { shallowMount } from '@vue/test-utils'; +import FrontmatterWrapper from '~/content_editor/components/wrappers/frontmatter.vue'; + +describe('content/components/wrappers/frontmatter', () => { + let wrapper; + + const createWrapper = async (nodeAttrs = { language: 'yaml' }) => { + wrapper = shallowMount(FrontmatterWrapper, { + propsData: { + node: { + attrs: nodeAttrs, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a node-view-wrapper as a pre element', () => { + createWrapper(); + + expect(wrapper.findComponent(NodeViewWrapper).props().as).toBe('pre'); + expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('gl-relative'); + }); + + it('renders a node-view-content as a code element', () => { + createWrapper(); + + expect(wrapper.findComponent(NodeViewContent).props().as).toBe('code'); + }); + + it('renders label indicating that code block is frontmatter', () => { + createWrapper(); + + const label = wrapper.find('[data-testid="frontmatter-label"]'); + + expect(label.text()).toEqual('frontmatter:yaml'); + expect(label.classes()).toEqual(['gl-absolute', 'gl-top-0', 'gl-right-3']); + }); +}); diff --git a/spec/frontend/content_editor/extensions/color_chip_spec.js b/spec/frontend/content_editor/extensions/color_chip_spec.js new file mode 100644 index 00000000000..4bb6f344ab4 --- /dev/null +++ b/spec/frontend/content_editor/extensions/color_chip_spec.js @@ -0,0 +1,33 @@ +import ColorChip, { colorDecoratorPlugin } from '~/content_editor/extensions/color_chip'; +import Code from '~/content_editor/extensions/code'; +import { createTestEditor } from '../test_utils'; + +describe('content_editor/extensions/color_chip', () => { + let tiptapEditor; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [ColorChip, Code] }); + }); + + describe.each` + colorExpression | decorated + ${'#F00'} | ${true} + ${'rgba(0,0,0,0)'} | ${true} + ${'hsl(540,70%,50%)'} | ${true} + ${'F00'} | ${false} + ${'F00'} | ${false} + ${'gba(0,0,0,0)'} | ${false} + ${'hls(540,70%,50%)'} | ${false} + ${'red'} | ${false} + `( + 'when a code span with $colorExpression color expression is found', + ({ colorExpression, decorated }) => { + it(`${decorated ? 'adds' : 'does not add'} a color chip decorator`, () => { + tiptapEditor.commands.setContent(`<p><code>${colorExpression}</code></p>`); + const pluginState = colorDecoratorPlugin.getState(tiptapEditor.state); + + expect(pluginState.children).toHaveLength(decorated ? 3 : 0); + }); + }, + ); +}); diff --git a/spec/frontend/content_editor/extensions/details_content_spec.js b/spec/frontend/content_editor/extensions/details_content_spec.js new file mode 100644 index 00000000000..575f3bf65e4 --- /dev/null +++ b/spec/frontend/content_editor/extensions/details_content_spec.js @@ -0,0 +1,76 @@ +import Details from '~/content_editor/extensions/details'; +import DetailsContent from '~/content_editor/extensions/details_content'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/details_content', () => { + let tiptapEditor; + let doc; + let p; + let details; + let detailsContent; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [Details, DetailsContent] }); + + ({ + builders: { doc, p, details, detailsContent }, + } = createDocBuilder({ + tiptapEditor, + names: { + details: { nodeType: Details.name }, + detailsContent: { nodeType: DetailsContent.name }, + }, + })); + }); + + describe('shortcut: Enter', () => { + it('splits a details content into two items', () => { + const initialDoc = doc( + details( + detailsContent(p('Summary')), + detailsContent(p('Text content')), + detailsContent(p('Text content')), + ), + ); + const expectedDoc = doc( + details( + detailsContent(p('Summary')), + detailsContent(p('')), + detailsContent(p('Text content')), + detailsContent(p('Text content')), + ), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + tiptapEditor.commands.setTextSelection(10); + tiptapEditor.commands.keyboardShortcut('Enter'); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('shortcut: Shift-Tab', () => { + it('lifts a details content and creates two separate details items', () => { + const initialDoc = doc( + details( + detailsContent(p('Summary')), + detailsContent(p('Text content')), + detailsContent(p('Text content')), + ), + ); + const expectedDoc = doc( + details(detailsContent(p('Summary'))), + p('Text content'), + details(detailsContent(p('Text content'))), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + tiptapEditor.commands.setTextSelection(20); + tiptapEditor.commands.keyboardShortcut('Shift-Tab'); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); +}); diff --git a/spec/frontend/content_editor/extensions/details_spec.js b/spec/frontend/content_editor/extensions/details_spec.js new file mode 100644 index 00000000000..cd59943982f --- /dev/null +++ b/spec/frontend/content_editor/extensions/details_spec.js @@ -0,0 +1,92 @@ +import Details from '~/content_editor/extensions/details'; +import DetailsContent from '~/content_editor/extensions/details_content'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/details', () => { + let tiptapEditor; + let doc; + let p; + let details; + let detailsContent; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [Details, DetailsContent] }); + + ({ + builders: { doc, p, details, detailsContent }, + } = createDocBuilder({ + tiptapEditor, + names: { + details: { nodeType: Details.name }, + detailsContent: { nodeType: DetailsContent.name }, + }, + })); + }); + + describe('setDetails command', () => { + describe('when current block is a paragraph', () => { + it('converts current paragraph into a details block', () => { + const initialDoc = doc(p('Text content')); + const expectedDoc = doc(details(detailsContent(p('Text content')))); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.setDetails(); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('when current block is a details block', () => { + it('maintains the same document structure', () => { + const initialDoc = doc(details(detailsContent(p('Text content')))); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.setDetails(); + + expect(tiptapEditor.getJSON()).toEqual(initialDoc.toJSON()); + }); + }); + }); + + describe('toggleDetails command', () => { + describe('when current block is a paragraph', () => { + it('converts current paragraph into a details block', () => { + const initialDoc = doc(p('Text content')); + const expectedDoc = doc(details(detailsContent(p('Text content')))); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.toggleDetails(); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('when current block is a details block', () => { + it('convert details block into a paragraph', () => { + const initialDoc = doc(details(detailsContent(p('Text content')))); + const expectedDoc = doc(p('Text content')); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.toggleDetails(); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + }); + + it.each` + input | insertedNode + ${'<details>'} | ${(...args) => details(detailsContent(p(...args)))} + ${'<details'} | ${(...args) => p(...args)} + ${'details>'} | ${(...args) => p(...args)} + `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { + const { view } = tiptapEditor; + const { selection } = view.state; + const expectedDoc = doc(insertedNode()); + + // Triggers the event handler that input rules listen to + view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, input)); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); +}); diff --git a/spec/frontend/content_editor/extensions/math_inline_spec.js b/spec/frontend/content_editor/extensions/math_inline_spec.js new file mode 100644 index 00000000000..82eb85477de --- /dev/null +++ b/spec/frontend/content_editor/extensions/math_inline_spec.js @@ -0,0 +1,42 @@ +import MathInline from '~/content_editor/extensions/math_inline'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/math_inline', () => { + let tiptapEditor; + let doc; + let p; + let mathInline; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [MathInline] }); + + ({ + builders: { doc, p, mathInline }, + } = createDocBuilder({ + tiptapEditor, + names: { + details: { markType: MathInline.name }, + }, + })); + }); + + it.each` + input | insertedNode + ${'$`a^2`$'} | ${() => p(mathInline('a^2'))} + ${'$`a^2`'} | ${() => p('$`a^2`')} + ${'`a^2`$'} | ${() => p('`a^2`$')} + `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { + const { view } = tiptapEditor; + const expectedDoc = doc(insertedNode()); + + tiptapEditor.chain().setContent(input).setTextSelection(0).run(); + + const { state } = tiptapEditor; + const { selection } = state; + + // Triggers the event handler that input rules listen to + view.someProp('handleTextInput', (f) => f(view, selection.from, input.length + 1, input)); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); +}); diff --git a/spec/frontend/content_editor/extensions/table_of_contents_spec.js b/spec/frontend/content_editor/extensions/table_of_contents_spec.js new file mode 100644 index 00000000000..83818899c17 --- /dev/null +++ b/spec/frontend/content_editor/extensions/table_of_contents_spec.js @@ -0,0 +1,35 @@ +import TableOfContents from '~/content_editor/extensions/table_of_contents'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/emoji', () => { + let tiptapEditor; + let builders; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [TableOfContents] }); + ({ builders } = createDocBuilder({ + tiptapEditor, + names: { tableOfContents: { nodeType: TableOfContents.name } }, + })); + }); + + it.each` + input | insertedNode + ${'[[_TOC_]]'} | ${'tableOfContents'} + ${'[TOC]'} | ${'tableOfContents'} + ${'[toc]'} | ${'p'} + ${'TOC'} | ${'p'} + ${'[_TOC_]'} | ${'p'} + ${'[[TOC]]'} | ${'p'} + `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { + const { doc } = builders; + const { view } = tiptapEditor; + const { selection } = view.state; + const expectedDoc = doc(builders[insertedNode]()); + + // Triggers the event handler that input rules listen to + view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, input)); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); +}); diff --git a/spec/frontend/content_editor/markdown_processing_examples.js b/spec/frontend/content_editor/markdown_processing_examples.js index b3aabfeb145..da895970289 100644 --- a/spec/frontend/content_editor/markdown_processing_examples.js +++ b/spec/frontend/content_editor/markdown_processing_examples.js @@ -1,11 +1,13 @@ import fs from 'fs'; import path from 'path'; import jsYaml from 'js-yaml'; +// eslint-disable-next-line import/no-deprecated import { getJSONFixture } from 'helpers/fixtures'; export const loadMarkdownApiResult = (testName) => { const fixturePathPrefix = `api/markdown/${testName}.json`; + // eslint-disable-next-line import/no-deprecated const fixture = getJSONFixture(fixturePathPrefix); return fixture.body || fixture.html; }; diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 6f2c908c289..33056ab9e4a 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -5,6 +5,8 @@ import Code from '~/content_editor/extensions/code'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; import DescriptionItem from '~/content_editor/extensions/description_item'; import DescriptionList from '~/content_editor/extensions/description_list'; +import Details from '~/content_editor/extensions/details'; +import DetailsContent from '~/content_editor/extensions/details_content'; import Division from '~/content_editor/extensions/division'; import Emoji from '~/content_editor/extensions/emoji'; import Figure from '~/content_editor/extensions/figure'; @@ -45,6 +47,8 @@ const tiptapEditor = createTestEditor({ CodeBlockHighlight, DescriptionItem, DescriptionList, + Details, + DetailsContent, Division, Emoji, Figure, @@ -78,6 +82,8 @@ const { bulletList, code, codeBlock, + details, + detailsContent, division, descriptionItem, descriptionList, @@ -110,6 +116,8 @@ const { bulletList: { nodeType: BulletList.name }, code: { markType: Code.name }, codeBlock: { nodeType: CodeBlockHighlight.name }, + details: { nodeType: Details.name }, + detailsContent: { nodeType: DetailsContent.name }, division: { nodeType: Division.name }, descriptionItem: { nodeType: DescriptionItem.name }, descriptionList: { nodeType: DescriptionList.name }, @@ -588,6 +596,105 @@ A giant _owl-like_ creature. ); }); + it('correctly renders a simple details/summary', () => { + expect( + serialize( + details( + detailsContent(paragraph('this is the summary')), + detailsContent(paragraph('this content will be hidden')), + ), + ), + ).toBe( + ` +<details> +<summary>this is the summary</summary> +this content will be hidden +</details> + `.trim(), + ); + }); + + it('correctly renders details/summary with styled content', () => { + expect( + serialize( + details( + detailsContent(paragraph('this is the ', bold('summary'))), + detailsContent( + codeBlock( + { language: 'javascript' }, + 'var a = 2;\nvar b = 3;\nvar c = a + d;\n\nconsole.log(c);', + ), + ), + detailsContent(paragraph('this content will be ', italic('hidden'))), + ), + details(detailsContent(paragraph('summary 2')), detailsContent(paragraph('content 2'))), + ), + ).toBe( + ` +<details> +<summary> + +this is the **summary** + +</summary> + +\`\`\`javascript +var a = 2; +var b = 3; +var c = a + d; + +console.log(c); +\`\`\` + +this content will be _hidden_ + +</details> +<details> +<summary>summary 2</summary> +content 2 +</details> + `.trim(), + ); + }); + + it('correctly renders nested details', () => { + expect( + serialize( + details( + detailsContent(paragraph('dream level 1')), + detailsContent( + details( + detailsContent(paragraph('dream level 2')), + detailsContent( + details( + detailsContent(paragraph('dream level 3')), + detailsContent(paragraph(italic('inception'))), + ), + ), + ), + ), + ), + ), + ).toBe( + ` +<details> +<summary>dream level 1</summary> + +<details> +<summary>dream level 2</summary> + +<details> +<summary>dream level 3</summary> + +_inception_ + +</details> +</details> +</details> + `.trim(), + ); + }); + it('correctly renders div', () => { expect( serialize( diff --git a/spec/frontend/cycle_analytics/base_spec.js b/spec/frontend/cycle_analytics/base_spec.js index 5d3361bfa35..9a9415cc12a 100644 --- a/spec/frontend/cycle_analytics/base_spec.js +++ b/spec/frontend/cycle_analytics/base_spec.js @@ -19,6 +19,7 @@ import { createdAfter, currentGroup, stageCounts, + initialPaginationState as pagination, } from './mock_data'; const selectedStageEvents = issueEvents.events; @@ -81,6 +82,7 @@ const findOverviewMetrics = () => wrapper.findComponent(ValueStreamMetrics); const findStageTable = () => wrapper.findComponent(StageTable); const findStageEvents = () => findStageTable().props('stageEvents'); const findEmptyStageTitle = () => wrapper.findComponent(GlEmptyState).props('title'); +const findPagination = () => wrapper.findByTestId('vsa-stage-pagination'); const hasMetricsRequests = (reqs) => { const foundReqs = findOverviewMetrics().props('requests'); @@ -90,7 +92,7 @@ const hasMetricsRequests = (reqs) => { describe('Value stream analytics component', () => { beforeEach(() => { - wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents } }); + wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents, pagination } }); }); afterEach(() => { @@ -153,6 +155,10 @@ describe('Value stream analytics component', () => { expect(findLoadingIcon().exists()).toBe(false); }); + it('renders pagination', () => { + expect(findPagination().exists()).toBe(true); + }); + describe('with `cycleAnalyticsForGroups=true` license', () => { beforeEach(() => { wrapper = createComponent({ initialState: { features: { cycleAnalyticsForGroups: true } } }); diff --git a/spec/frontend/cycle_analytics/mock_data.js b/spec/frontend/cycle_analytics/mock_data.js index d9659d5d4c3..1882457960a 100644 --- a/spec/frontend/cycle_analytics/mock_data.js +++ b/spec/frontend/cycle_analytics/mock_data.js @@ -1,6 +1,14 @@ +/* eslint-disable import/no-deprecated */ + import { getJSONFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; -import { DEFAULT_VALUE_STREAM, DEFAULT_DAYS_IN_PAST } from '~/cycle_analytics/constants'; +import { + DEFAULT_VALUE_STREAM, + DEFAULT_DAYS_IN_PAST, + PAGINATION_TYPE, + PAGINATION_SORT_DIRECTION_DESC, + PAGINATION_SORT_FIELD_END_EVENT, +} from '~/cycle_analytics/constants'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { getDateInPast } from '~/lib/utils/datetime_utility'; @@ -13,9 +21,10 @@ export const getStageByTitle = (stages, title) => stages.find((stage) => stage.title && stage.title.toLowerCase().trim() === title) || {}; const fixtureEndpoints = { - customizableCycleAnalyticsStagesAndEvents: 'projects/analytics/value_stream_analytics/stages', - stageEvents: (stage) => `projects/analytics/value_stream_analytics/events/${stage}`, - metricsData: 'projects/analytics/value_stream_analytics/summary', + customizableCycleAnalyticsStagesAndEvents: + 'projects/analytics/value_stream_analytics/stages.json', + stageEvents: (stage) => `projects/analytics/value_stream_analytics/events/${stage}.json`, + metricsData: 'projects/analytics/value_stream_analytics/summary.json', }; export const metricsData = getJSONFixture(fixtureEndpoints.metricsData); @@ -256,3 +265,22 @@ export const rawValueStreamStages = customizableStagesAndEvents.stages; export const valueStreamStages = rawValueStreamStages.map((s) => convertObjectPropsToCamelCase(s, { deep: true }), ); + +export const initialPaginationQuery = { + page: 15, + sort: PAGINATION_SORT_FIELD_END_EVENT, + direction: PAGINATION_SORT_DIRECTION_DESC, +}; + +export const initialPaginationState = { + ...initialPaginationQuery, + page: null, + hasNextPage: false, +}; + +export const basePaginationResult = { + pagination: PAGINATION_TYPE, + sort: PAGINATION_SORT_FIELD_END_EVENT, + direction: PAGINATION_SORT_DIRECTION_DESC, + page: null, +}; diff --git a/spec/frontend/cycle_analytics/store/actions_spec.js b/spec/frontend/cycle_analytics/store/actions_spec.js index 97b5bd03e18..993e6b6b73a 100644 --- a/spec/frontend/cycle_analytics/store/actions_spec.js +++ b/spec/frontend/cycle_analytics/store/actions_spec.js @@ -11,6 +11,8 @@ import { currentGroup, createdAfter, createdBefore, + initialPaginationState, + reviewEvents, } from '../mock_data'; const { id: groupId, path: groupPath } = currentGroup; @@ -31,7 +33,13 @@ const mockSetDateActionCommit = { type: 'SET_DATE_RANGE', }; -const defaultState = { ...getters, selectedValueStream, createdAfter, createdBefore }; +const defaultState = { + ...getters, + selectedValueStream, + createdAfter, + createdBefore, + pagination: initialPaginationState, +}; describe('Project Value Stream Analytics actions', () => { let state; @@ -112,6 +120,21 @@ describe('Project Value Stream Analytics actions', () => { }); }); + describe('updateStageTablePagination', () => { + beforeEach(() => { + state = { ...state, selectedStage }; + }); + + it(`will dispatch the "fetchStageData" action and commit the 'SET_PAGINATION' mutation`, () => { + return testAction({ + action: actions.updateStageTablePagination, + state, + expectedMutations: [{ type: 'SET_PAGINATION' }], + expectedActions: [{ type: 'fetchStageData', payload: selectedStage.id }], + }); + }); + }); + describe('fetchCycleAnalyticsData', () => { beforeEach(() => { state = { ...defaultState, endpoints: mockEndpoints }; @@ -154,6 +177,10 @@ describe('Project Value Stream Analytics actions', () => { describe('fetchStageData', () => { const mockStagePath = /value_streams\/\w+\/stages\/\w+\/records/; + const headers = { + 'X-Next-Page': 2, + 'X-Page': 1, + }; beforeEach(() => { state = { @@ -162,7 +189,7 @@ describe('Project Value Stream Analytics actions', () => { selectedStage, }; mock = new MockAdapter(axios); - mock.onGet(mockStagePath).reply(httpStatusCodes.OK); + mock.onGet(mockStagePath).reply(httpStatusCodes.OK, reviewEvents, headers); }); it(`commits the 'RECEIVE_STAGE_DATA_SUCCESS' mutation`, () => @@ -170,7 +197,11 @@ describe('Project Value Stream Analytics actions', () => { action: actions.fetchStageData, state, payload: {}, - expectedMutations: [{ type: 'REQUEST_STAGE_DATA' }, { type: 'RECEIVE_STAGE_DATA_SUCCESS' }], + expectedMutations: [ + { type: 'REQUEST_STAGE_DATA' }, + { type: 'RECEIVE_STAGE_DATA_SUCCESS', payload: reviewEvents }, + { type: 'SET_PAGINATION', payload: { hasNextPage: true, page: 1 } }, + ], expectedActions: [], })); diff --git a/spec/frontend/cycle_analytics/store/getters_spec.js b/spec/frontend/cycle_analytics/store/getters_spec.js index c47a30a5f79..c9208045a68 100644 --- a/spec/frontend/cycle_analytics/store/getters_spec.js +++ b/spec/frontend/cycle_analytics/store/getters_spec.js @@ -1,17 +1,42 @@ import * as getters from '~/cycle_analytics/store/getters'; + import { allowedStages, stageMedians, transformedProjectStagePathData, selectedStage, stageCounts, + basePaginationResult, + initialPaginationState, } from '../mock_data'; describe('Value stream analytics getters', () => { + let state = {}; + describe('pathNavigationData', () => { it('returns the transformed data', () => { - const state = { stages: allowedStages, medians: stageMedians, selectedStage, stageCounts }; + state = { stages: allowedStages, medians: stageMedians, selectedStage, stageCounts }; expect(getters.pathNavigationData(state)).toEqual(transformedProjectStagePathData); }); }); + + describe('paginationParams', () => { + beforeEach(() => { + state = { pagination: initialPaginationState }; + }); + + it('returns the `pagination` type', () => { + expect(getters.paginationParams(state)).toEqual(basePaginationResult); + }); + + it('returns the `sort` type', () => { + expect(getters.paginationParams(state)).toEqual(basePaginationResult); + }); + + it('with page=10, sets the `page` property', () => { + const page = 10; + state = { pagination: { ...initialPaginationState, page } }; + expect(getters.paginationParams(state)).toEqual({ ...basePaginationResult, page }); + }); + }); }); diff --git a/spec/frontend/cycle_analytics/store/mutations_spec.js b/spec/frontend/cycle_analytics/store/mutations_spec.js index 628e2a4e7ae..4860225c995 100644 --- a/spec/frontend/cycle_analytics/store/mutations_spec.js +++ b/spec/frontend/cycle_analytics/store/mutations_spec.js @@ -2,6 +2,10 @@ import { useFakeDate } from 'helpers/fake_date'; import * as types from '~/cycle_analytics/store/mutation_types'; import mutations from '~/cycle_analytics/store/mutations'; import { + PAGINATION_SORT_FIELD_END_EVENT, + PAGINATION_SORT_DIRECTION_DESC, +} from '~/cycle_analytics/constants'; +import { selectedStage, rawIssueEvents, issueEvents, @@ -12,6 +16,7 @@ import { formattedStageMedians, rawStageCounts, stageCounts, + initialPaginationState as pagination, } from '../mock_data'; let state; @@ -25,7 +30,7 @@ describe('Project Value Stream Analytics mutations', () => { useFakeDate(2020, 6, 18); beforeEach(() => { - state = {}; + state = { pagination }; }); afterEach(() => { @@ -88,16 +93,18 @@ describe('Project Value Stream Analytics mutations', () => { }); it.each` - mutation | payload | stateKey | value - ${types.SET_DATE_RANGE} | ${mockSetDatePayload} | ${'createdAfter'} | ${mockCreatedAfter} - ${types.SET_DATE_RANGE} | ${mockSetDatePayload} | ${'createdBefore'} | ${mockCreatedBefore} - ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true} - ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false} - ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} - ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} - ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages} - ${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians} - ${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts} + mutation | payload | stateKey | value + ${types.SET_DATE_RANGE} | ${mockSetDatePayload} | ${'createdAfter'} | ${mockCreatedAfter} + ${types.SET_DATE_RANGE} | ${mockSetDatePayload} | ${'createdBefore'} | ${mockCreatedBefore} + ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true} + ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false} + ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} + ${types.SET_PAGINATION} | ${pagination} | ${'pagination'} | ${{ ...pagination, sort: PAGINATION_SORT_FIELD_END_EVENT, direction: PAGINATION_SORT_DIRECTION_DESC }} + ${types.SET_PAGINATION} | ${{ ...pagination, sort: 'duration', direction: 'asc' }} | ${'pagination'} | ${{ ...pagination, sort: 'duration', direction: 'asc' }} + ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} + ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages} + ${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians} + ${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts} `( '$mutation with $payload will set $stateKey to $value', ({ mutation, payload, stateKey, value }) => { diff --git a/spec/frontend/cycle_analytics/utils_spec.js b/spec/frontend/cycle_analytics/utils_spec.js index 69fed879fd8..74d64cd8d71 100644 --- a/spec/frontend/cycle_analytics/utils_spec.js +++ b/spec/frontend/cycle_analytics/utils_spec.js @@ -1,7 +1,6 @@ import { useFakeDate } from 'helpers/fake_date'; import { transformStagesForPathNavigation, - timeSummaryForPathNavigation, medianTimeToParsedSeconds, formatMedianValues, filterStagesByHiddenStatus, @@ -47,21 +46,6 @@ describe('Value stream analytics utils', () => { }); }); - describe('timeSummaryForPathNavigation', () => { - it.each` - unit | value | result - ${'months'} | ${1.5} | ${'1.5M'} - ${'weeks'} | ${1.25} | ${'1.5w'} - ${'days'} | ${2} | ${'2d'} - ${'hours'} | ${10} | ${'10h'} - ${'minutes'} | ${20} | ${'20m'} - ${'seconds'} | ${10} | ${'<1m'} - ${'seconds'} | ${0} | ${'-'} - `('will format $value $unit to $result', ({ unit, value, result }) => { - expect(timeSummaryForPathNavigation({ [unit]: value })).toBe(result); - }); - }); - describe('medianTimeToParsedSeconds', () => { it.each` value | result diff --git a/spec/frontend/deploy_freeze/helpers.js b/spec/frontend/deploy_freeze/helpers.js index 598f14d45f6..43e66183ab5 100644 --- a/spec/frontend/deploy_freeze/helpers.js +++ b/spec/frontend/deploy_freeze/helpers.js @@ -1,7 +1,8 @@ +import freezePeriodsFixture from 'test_fixtures/api/freeze-periods/freeze_periods.json'; +import timezoneDataFixture from 'test_fixtures/timezones/short.json'; import { secondsToHours } from '~/lib/utils/datetime_utility'; -export const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json'); -export const timezoneDataFixture = getJSONFixture('/timezones/short.json'); +export { freezePeriodsFixture, timezoneDataFixture }; export const findTzByName = (identifier = '') => timezoneDataFixture.find(({ name }) => name.toLowerCase() === identifier.toLowerCase()); diff --git a/spec/frontend/deploy_keys/components/action_btn_spec.js b/spec/frontend/deploy_keys/components/action_btn_spec.js index 307a0b6d8b0..6ac68061518 100644 --- a/spec/frontend/deploy_keys/components/action_btn_spec.js +++ b/spec/frontend/deploy_keys/components/action_btn_spec.js @@ -1,10 +1,10 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import data from 'test_fixtures/deploy_keys/keys.json'; import actionBtn from '~/deploy_keys/components/action_btn.vue'; import eventHub from '~/deploy_keys/eventhub'; describe('Deploy keys action btn', () => { - const data = getJSONFixture('deploy_keys/keys.json'); const deployKey = data.enabled_keys[0]; let wrapper; diff --git a/spec/frontend/deploy_keys/components/app_spec.js b/spec/frontend/deploy_keys/components/app_spec.js index a72b2b00776..598b7a0f173 100644 --- a/spec/frontend/deploy_keys/components/app_spec.js +++ b/spec/frontend/deploy_keys/components/app_spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import data from 'test_fixtures/deploy_keys/keys.json'; import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; import deployKeysApp from '~/deploy_keys/components/app.vue'; @@ -10,7 +11,6 @@ import axios from '~/lib/utils/axios_utils'; const TEST_ENDPOINT = `${TEST_HOST}/dummy/`; describe('Deploy keys app component', () => { - const data = getJSONFixture('deploy_keys/keys.json'); let wrapper; let mock; diff --git a/spec/frontend/deploy_keys/components/key_spec.js b/spec/frontend/deploy_keys/components/key_spec.js index 5420f9a01f9..511b9d6ef55 100644 --- a/spec/frontend/deploy_keys/components/key_spec.js +++ b/spec/frontend/deploy_keys/components/key_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import data from 'test_fixtures/deploy_keys/keys.json'; import key from '~/deploy_keys/components/key.vue'; import DeployKeysStore from '~/deploy_keys/store'; import { getTimeago } from '~/lib/utils/datetime_utility'; @@ -7,8 +8,6 @@ describe('Deploy keys key', () => { let wrapper; let store; - const data = getJSONFixture('deploy_keys/keys.json'); - const findTextAndTrim = (selector) => wrapper.find(selector).text().trim(); const createComponent = (propsData) => { diff --git a/spec/frontend/deploy_keys/components/keys_panel_spec.js b/spec/frontend/deploy_keys/components/keys_panel_spec.js index d6419356166..f3b907e5450 100644 --- a/spec/frontend/deploy_keys/components/keys_panel_spec.js +++ b/spec/frontend/deploy_keys/components/keys_panel_spec.js @@ -1,9 +1,9 @@ import { mount } from '@vue/test-utils'; +import data from 'test_fixtures/deploy_keys/keys.json'; import deployKeysPanel from '~/deploy_keys/components/keys_panel.vue'; import DeployKeysStore from '~/deploy_keys/store'; describe('Deploy keys panel', () => { - const data = getJSONFixture('deploy_keys/keys.json'); let wrapper; const findTableRowHeader = () => wrapper.find('.table-row-header'); diff --git a/spec/frontend/deprecated_jquery_dropdown_spec.js b/spec/frontend/deprecated_jquery_dropdown_spec.js index 4a6dee31cd5..7e4c6e131b4 100644 --- a/spec/frontend/deprecated_jquery_dropdown_spec.js +++ b/spec/frontend/deprecated_jquery_dropdown_spec.js @@ -3,6 +3,8 @@ import $ from 'jquery'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import '~/lib/utils/common_utils'; +// eslint-disable-next-line import/no-deprecated +import { getJSONFixture } from 'helpers/fixtures'; import { visitUrl } from '~/lib/utils/url_utility'; jest.mock('~/lib/utils/url_utility', () => ({ @@ -66,6 +68,7 @@ describe('deprecatedJQueryDropdown', () => { loadFixtures('static/deprecated_jquery_dropdown.html'); test.dropdownContainerElement = $('.dropdown.inline'); test.$dropdownMenuElement = $('.dropdown-menu', test.dropdownContainerElement); + // eslint-disable-next-line import/no-deprecated test.projectsData = getJSONFixture('static/projects.json'); }); diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap index 67e4a82787c..2b706d21f51 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap @@ -4,13 +4,13 @@ exports[`Design management design version dropdown component renders design vers <gl-dropdown-stub category="primary" clearalltext="Clear all" + clearalltextclass="gl-px-5" headertext="" hideheaderborder="true" highlighteditemstitle="Selected" highlighteditemstitleclass="gl-px-5" issueiid="" projectpath="" - showhighlighteditemstitle="true" size="small" text="Showing latest version" variant="default" @@ -85,13 +85,13 @@ exports[`Design management design version dropdown component renders design vers <gl-dropdown-stub category="primary" clearalltext="Clear all" + clearalltextclass="gl-px-5" headertext="" hideheaderborder="true" highlighteditemstitle="Selected" highlighteditemstitleclass="gl-px-5" issueiid="" projectpath="" - showhighlighteditemstitle="true" size="small" text="Showing latest version" variant="default" diff --git a/spec/frontend/design_management/utils/cache_update_spec.js b/spec/frontend/design_management/utils/cache_update_spec.js index 7327cf00abd..fa6a666bb37 100644 --- a/spec/frontend/design_management/utils/cache_update_spec.js +++ b/spec/frontend/design_management/utils/cache_update_spec.js @@ -26,11 +26,11 @@ describe('Design Management cache update', () => { describe('error handling', () => { it.each` - fnName | subject | errorMessage | extraArgs - ${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError({ singular: true })} | ${[[design]]} - ${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]} - ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]} - ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterRepositionImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]} + fnName | subject | errorMessage | extraArgs + ${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError()} | ${[[design]]} + ${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]} + ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]} + ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterRepositionImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]} `('$fnName handles errors in response', ({ subject, extraArgs, errorMessage }) => { expect(createFlash).not.toHaveBeenCalled(); expect(() => subject(mockStore, { errors: mockErrors }, {}, ...extraArgs)).toThrow(); diff --git a/spec/frontend/design_management/utils/error_messages_spec.js b/spec/frontend/design_management/utils/error_messages_spec.js index b80dcd9abde..4994f4f6fd0 100644 --- a/spec/frontend/design_management/utils/error_messages_spec.js +++ b/spec/frontend/design_management/utils/error_messages_spec.js @@ -10,20 +10,21 @@ const mockFilenames = (n) => describe('Error message', () => { describe('designDeletionError', () => { - const singularMsg = 'Could not archive a design. Please try again.'; - const pluralMsg = 'Could not archive designs. Please try again.'; + const singularMsg = 'Failed to archive a design. Please try again.'; + const pluralMsg = 'Failed to archive designs. Please try again.'; - describe('when [singular=true]', () => { - it.each([[undefined], [true]])('uses singular grammar', (singularOption) => { - expect(designDeletionError({ singular: singularOption })).toEqual(singularMsg); - }); - }); - - describe('when [singular=false]', () => { - it('uses plural grammar', () => { - expect(designDeletionError({ singular: false })).toEqual(pluralMsg); - }); - }); + it.each` + designsLength | expectedText + ${undefined} | ${singularMsg} + ${0} | ${pluralMsg} + ${1} | ${singularMsg} + ${2} | ${pluralMsg} + `( + 'returns "$expectedText" when designsLength is $designsLength', + ({ designsLength, expectedText }) => { + expect(designDeletionError(designsLength)).toBe(expectedText); + }, + ); }); describe.each([ @@ -47,12 +48,12 @@ describe('Error message', () => { [ mockFilenames(7), mockFilenames(6), - 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg, and 1 more.', + 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg and 1 more.', ], [ mockFilenames(8), mockFilenames(7), - 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg, and 2 more.', + 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg and 2 more.', ], ])('designUploadSkippedWarning', (uploadedFiles, skippedFiles, expected) => { it('returns expected warning message', () => { diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 9dc82bbdc93..0527c2153f4 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -13,11 +13,8 @@ import DiffFile from '~/diffs/components/diff_file.vue'; import NoChanges from '~/diffs/components/no_changes.vue'; import TreeList from '~/diffs/components/tree_list.vue'; -/* eslint-disable import/order */ -/* You know what: sometimes alphabetical isn't the best order */ import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue'; import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue'; -/* eslint-enable import/order */ import axios from '~/lib/utils/axios_utils'; import * as urlUtils from '~/lib/utils/url_utility'; @@ -705,4 +702,23 @@ describe('diffs/components/app', () => { ); }); }); + + describe('fluid layout', () => { + beforeEach(() => { + setFixtures( + '<div><div class="merge-request-container limit-container-width container-limited"></div></div>', + ); + }); + + it('removes limited container classes when on diffs tab', () => { + createComponent({ isFluidLayout: false, shouldShow: true }, () => {}, { + glFeatures: { mrChangesFluidLayout: true }, + }); + + const containerClassList = document.querySelector('.merge-request-container').classList; + + expect(containerClassList).not.toContain('container-limited'); + expect(containerClassList).not.toContain('limit-container-width'); + }); + }); }); diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js index 0191822d97a..d887029124f 100644 --- a/spec/frontend/diffs/components/commit_item_spec.js +++ b/spec/frontend/diffs/components/commit_item_spec.js @@ -1,10 +1,10 @@ import { mount } from '@vue/test-utils'; +import getDiffWithCommit from 'test_fixtures/merge_request_diffs/with_commit.json'; import { TEST_HOST } from 'helpers/test_constants'; import { trimText } from 'helpers/text_helper'; import Component from '~/diffs/components/commit_item.vue'; import { getTimeago } from '~/lib/utils/datetime_utility'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; -import getDiffWithCommit from '../mock_data/diff_with_commit'; jest.mock('~/user_popovers'); @@ -18,7 +18,7 @@ describe('diffs/components/commit_item', () => { let wrapper; const timeago = getTimeago(); - const { commit } = getDiffWithCommit(); + const { commit } = getDiffWithCommit; const getTitleElement = () => wrapper.find('.commit-row-message.item-title'); const getDescElement = () => wrapper.find('pre.commit-row-description'); diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js index 1c0cb1193fa..c48935bc4f0 100644 --- a/spec/frontend/diffs/components/compare_versions_spec.js +++ b/spec/frontend/diffs/components/compare_versions_spec.js @@ -1,11 +1,11 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import getDiffWithCommit from 'test_fixtures/merge_request_diffs/with_commit.json'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import { trimText } from 'helpers/text_helper'; import CompareVersionsComponent from '~/diffs/components/compare_versions.vue'; import { createStore } from '~/mr_notes/stores'; -import getDiffWithCommit from '../mock_data/diff_with_commit'; import diffsMockData from '../mock_data/merge_request_diffs'; const localVue = createLocalVue(); @@ -22,7 +22,7 @@ describe('CompareVersions', () => { let wrapper; let store; const targetBranchName = 'tmp-wine-dev'; - const { commit } = getDiffWithCommit(); + const { commit } = getDiffWithCommit; const createWrapper = (props = {}, commitArgs = {}, createCommit = true) => { if (createCommit) { @@ -150,7 +150,7 @@ describe('CompareVersions', () => { describe('commit', () => { beforeEach(() => { - store.state.diffs.commit = getDiffWithCommit().commit; + store.state.diffs.commit = getDiffWithCommit.commit; createWrapper(); }); diff --git a/spec/frontend/diffs/mock_data/diff_with_commit.js b/spec/frontend/diffs/mock_data/diff_with_commit.js deleted file mode 100644 index f3b39bd3577..00000000000 --- a/spec/frontend/diffs/mock_data/diff_with_commit.js +++ /dev/null @@ -1,5 +0,0 @@ -const FIXTURE = 'merge_request_diffs/with_commit.json'; - -export default function getDiffWithCommit() { - return getJSONFixture(FIXTURE); -} diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index b35abc9da02..85734e05aeb 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -51,7 +51,7 @@ import { } from '~/diffs/store/actions'; import * as types from '~/diffs/store/mutation_types'; import * as utils from '~/diffs/store/utils'; -import * as workerUtils from '~/diffs/utils/workers'; +import * as treeWorkerUtils from '~/diffs/utils/tree_worker_utils'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as commonUtils from '~/lib/utils/common_utils'; @@ -253,7 +253,7 @@ describe('DiffsStoreActions', () => { // Workers are synchronous in Jest environment (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/58805) { type: types.SET_TREE_DATA, - payload: workerUtils.generateTreeList(diffMetadata.diff_files), + payload: treeWorkerUtils.generateTreeList(diffMetadata.diff_files), }, ], [], diff --git a/spec/frontend/diffs/utils/workers_spec.js b/spec/frontend/diffs/utils/tree_worker_utils_spec.js index 25d8183b777..8113428f712 100644 --- a/spec/frontend/diffs/utils/workers_spec.js +++ b/spec/frontend/diffs/utils/tree_worker_utils_spec.js @@ -1,6 +1,10 @@ -import { generateTreeList, getLowestSingleFolder, flattenTree } from '~/diffs/utils/workers'; +import { + generateTreeList, + getLowestSingleFolder, + flattenTree, +} from '~/diffs/utils/tree_worker_utils'; -describe('~/diffs/utils/workers', () => { +describe('~/diffs/utils/tree_worker_utils', () => { describe('generateTreeList', () => { let files; diff --git a/spec/frontend/editor/source_editor_ci_schema_ext_spec.js b/spec/frontend/editor/source_editor_ci_schema_ext_spec.js index 07ac080fe08..8a0d1ecf1af 100644 --- a/spec/frontend/editor/source_editor_ci_schema_ext_spec.js +++ b/spec/frontend/editor/source_editor_ci_schema_ext_spec.js @@ -1,7 +1,7 @@ import { languages } from 'monaco-editor'; import { TEST_HOST } from 'helpers/test_constants'; -import { EXTENSION_CI_SCHEMA_FILE_NAME_MATCH } from '~/editor/constants'; import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext'; +import ciSchemaPath from '~/editor/schema/ci.json'; import SourceEditor from '~/editor/source_editor'; const mockRef = 'AABBCCDD'; @@ -84,7 +84,7 @@ describe('~/editor/editor_ci_config_ext', () => { }); expect(getConfiguredYmlSchema()).toEqual({ - uri: `${TEST_HOST}/${mockProjectNamespace}/${mockProjectPath}/-/schema/${mockRef}/${EXTENSION_CI_SCHEMA_FILE_NAME_MATCH}`, + uri: `${TEST_HOST}${ciSchemaPath}`, fileMatch: [defaultBlobPath], }); }); @@ -99,7 +99,7 @@ describe('~/editor/editor_ci_config_ext', () => { }); expect(getConfiguredYmlSchema()).toEqual({ - uri: `${TEST_HOST}/${mockProjectNamespace}/${mockProjectPath}/-/schema/${mockRef}/${EXTENSION_CI_SCHEMA_FILE_NAME_MATCH}`, + uri: `${TEST_HOST}${ciSchemaPath}`, fileMatch: ['another-ci-filename.yml'], }); }); diff --git a/spec/frontend/environments/environment_delete_spec.js b/spec/frontend/environments/environment_delete_spec.js index a8c288a3bd8..2d8cff0c74a 100644 --- a/spec/frontend/environments/environment_delete_spec.js +++ b/spec/frontend/environments/environment_delete_spec.js @@ -1,4 +1,4 @@ -import { GlButton } from '@gitlab/ui'; +import { GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import DeleteComponent from '~/environments/components/environment_delete.vue'; @@ -15,7 +15,7 @@ describe('External URL Component', () => { }); }; - const findButton = () => wrapper.find(GlButton); + const findDropdownItem = () => wrapper.find(GlDropdownItem); beforeEach(() => { jest.spyOn(window, 'confirm'); @@ -23,14 +23,15 @@ describe('External URL Component', () => { createWrapper(); }); - it('should render a button to delete the environment', () => { - expect(findButton().exists()).toBe(true); - expect(wrapper.attributes('title')).toEqual('Delete environment'); + it('should render a dropdown item to delete the environment', () => { + expect(findDropdownItem().exists()).toBe(true); + expect(wrapper.text()).toEqual('Delete environment'); + expect(findDropdownItem().attributes('variant')).toBe('danger'); }); it('emits requestDeleteEnvironment in the event hub when button is clicked', () => { jest.spyOn(eventHub, '$emit'); - findButton().vm.$emit('click'); + findDropdownItem().vm.$emit('click'); expect(eventHub.$emit).toHaveBeenCalledWith('requestDeleteEnvironment', wrapper.vm.environment); }); }); diff --git a/spec/frontend/environments/environment_monitoring_spec.js b/spec/frontend/environments/environment_monitoring_spec.js index 3a53b57c3c6..98dd9edd812 100644 --- a/spec/frontend/environments/environment_monitoring_spec.js +++ b/spec/frontend/environments/environment_monitoring_spec.js @@ -1,6 +1,6 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import MonitoringComponent from '~/environments/components/environment_monitoring.vue'; +import { __ } from '~/locale'; describe('Monitoring Component', () => { let wrapper; @@ -8,31 +8,19 @@ describe('Monitoring Component', () => { const monitoringUrl = 'https://gitlab.com'; const createWrapper = () => { - wrapper = shallowMount(MonitoringComponent, { + wrapper = mountExtended(MonitoringComponent, { propsData: { monitoringUrl, }, }); }; - const findButtons = () => wrapper.findAll(GlButton); - const findButtonsByIcon = (icon) => - findButtons().filter((button) => button.props('icon') === icon); - beforeEach(() => { createWrapper(); }); - describe('computed', () => { - it('title', () => { - expect(wrapper.vm.title).toBe('Monitoring'); - }); - }); - it('should render a link to environment monitoring page', () => { - expect(wrapper.attributes('href')).toEqual(monitoringUrl); - expect(findButtonsByIcon('chart').length).toBe(1); - expect(wrapper.attributes('title')).toBe('Monitoring'); - expect(wrapper.attributes('aria-label')).toBe('Monitoring'); + const link = wrapper.findByRole('menuitem', { name: __('Monitoring') }); + expect(link.attributes('href')).toEqual(monitoringUrl); }); }); diff --git a/spec/frontend/environments/environment_pin_spec.js b/spec/frontend/environments/environment_pin_spec.js index 5cdd52294b6..a9a58071e12 100644 --- a/spec/frontend/environments/environment_pin_spec.js +++ b/spec/frontend/environments/environment_pin_spec.js @@ -1,4 +1,4 @@ -import { GlButton, GlIcon } from '@gitlab/ui'; +import { GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import PinComponent from '~/environments/components/environment_pin.vue'; import eventHub from '~/environments/event_hub'; @@ -30,15 +30,15 @@ describe('Pin Component', () => { wrapper.destroy(); }); - it('should render the component with thumbtack icon', () => { - expect(wrapper.find(GlIcon).props('name')).toBe('thumbtack'); + it('should render the component with descriptive text', () => { + expect(wrapper.text()).toBe('Prevent auto-stopping'); }); it('should emit onPinClick when clicked', () => { const eventHubSpy = jest.spyOn(eventHub, '$emit'); - const button = wrapper.find(GlButton); + const item = wrapper.find(GlDropdownItem); - button.vm.$emit('click'); + item.vm.$emit('click'); expect(eventHubSpy).toHaveBeenCalledWith('cancelAutoStop', autoStopUrl); }); diff --git a/spec/frontend/environments/environment_rollback_spec.js b/spec/frontend/environments/environment_rollback_spec.js index b6c3d436c18..cde675cd9e7 100644 --- a/spec/frontend/environments/environment_rollback_spec.js +++ b/spec/frontend/environments/environment_rollback_spec.js @@ -1,5 +1,5 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; +import { GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import RollbackComponent from '~/environments/components/environment_rollback.vue'; import eventHub from '~/environments/event_hub'; @@ -7,7 +7,7 @@ describe('Rollback Component', () => { const retryUrl = 'https://gitlab.com/retry'; it('Should render Re-deploy label when isLastDeployment is true', () => { - const wrapper = mount(RollbackComponent, { + const wrapper = shallowMount(RollbackComponent, { propsData: { retryUrl, isLastDeployment: true, @@ -15,11 +15,11 @@ describe('Rollback Component', () => { }, }); - expect(wrapper.element).toHaveSpriteIcon('repeat'); + expect(wrapper.text()).toBe('Re-deploy to environment'); }); it('Should render Rollback label when isLastDeployment is false', () => { - const wrapper = mount(RollbackComponent, { + const wrapper = shallowMount(RollbackComponent, { propsData: { retryUrl, isLastDeployment: false, @@ -27,7 +27,7 @@ describe('Rollback Component', () => { }, }); - expect(wrapper.element).toHaveSpriteIcon('redo'); + expect(wrapper.text()).toBe('Rollback environment'); }); it('should emit a "rollback" event on button click', () => { @@ -40,7 +40,7 @@ describe('Rollback Component', () => { }, }, }); - const button = wrapper.find(GlButton); + const button = wrapper.find(GlDropdownItem); button.vm.$emit('click'); diff --git a/spec/frontend/environments/environment_terminal_button_spec.js b/spec/frontend/environments/environment_terminal_button_spec.js index 2475785a927..ab9f370595f 100644 --- a/spec/frontend/environments/environment_terminal_button_spec.js +++ b/spec/frontend/environments/environment_terminal_button_spec.js @@ -1,12 +1,13 @@ -import { shallowMount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import TerminalComponent from '~/environments/components/environment_terminal_button.vue'; +import { __ } from '~/locale'; -describe('Stop Component', () => { +describe('Terminal Component', () => { let wrapper; const terminalPath = '/path'; const mountWithProps = (props) => { - wrapper = shallowMount(TerminalComponent, { + wrapper = mountExtended(TerminalComponent, { propsData: props, }); }; @@ -15,17 +16,9 @@ describe('Stop Component', () => { mountWithProps({ terminalPath }); }); - describe('computed', () => { - it('title', () => { - expect(wrapper.vm.title).toEqual('Terminal'); - }); - }); - it('should render a link to open a web terminal with the provided path', () => { - expect(wrapper.element.tagName).toBe('A'); - expect(wrapper.attributes('title')).toBe('Terminal'); - expect(wrapper.attributes('aria-label')).toBe('Terminal'); - expect(wrapper.attributes('href')).toBe(terminalPath); + const link = wrapper.findByRole('menuitem', { name: __('Terminal') }); + expect(link.attributes('href')).toBe(terminalPath); }); it('should render a non-disabled button', () => { diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js index babbc0c8a4d..4e459d800e8 100644 --- a/spec/frontend/error_tracking/components/error_details_spec.js +++ b/spec/frontend/error_tracking/components/error_details_spec.js @@ -503,6 +503,53 @@ describe('ErrorDetails', () => { }); }); }); + + describe('Release links', () => { + const firstReleaseVersion = '7975be01'; + const firstCommitLink = '/gitlab/-/commit/7975be01'; + const firstReleaseLink = '/sentry/releases/7975be01'; + const findFirstCommitLink = () => wrapper.find(`[href$="${firstCommitLink}"]`); + const findFirstReleaseLink = () => wrapper.find(`[href$="${firstReleaseLink}"]`); + + const lastReleaseVersion = '6ca5a5c1'; + const lastCommitLink = '/gitlab/-/commit/6ca5a5c1'; + const lastReleaseLink = '/sentry/releases/6ca5a5c1'; + const findLastCommitLink = () => wrapper.find(`[href$="${lastCommitLink}"]`); + const findLastReleaseLink = () => wrapper.find(`[href$="${lastReleaseLink}"]`); + + it('should display links to Sentry', async () => { + mocks.$apollo.queries.error.loading = false; + await wrapper.setData({ + error: { + firstReleaseVersion, + lastReleaseVersion, + externalBaseUrl: '/sentry', + }, + }); + + expect(findFirstReleaseLink().exists()).toBe(true); + expect(findLastReleaseLink().exists()).toBe(true); + expect(findFirstCommitLink().exists()).toBe(false); + expect(findLastCommitLink().exists()).toBe(false); + }); + + it('should display links to GitLab when integrated', async () => { + mocks.$apollo.queries.error.loading = false; + await wrapper.setData({ + error: { + firstReleaseVersion, + lastReleaseVersion, + integrated: true, + externalBaseUrl: '/gitlab', + }, + }); + + expect(findFirstCommitLink().exists()).toBe(true); + expect(findLastCommitLink().exists()).toBe(true); + expect(findFirstReleaseLink().exists()).toBe(false); + expect(findLastReleaseLink().exists()).toBe(false); + }); + }); }); describe('Snowplow tracking', () => { diff --git a/spec/frontend/error_tracking_settings/components/app_spec.js b/spec/frontend/error_tracking_settings/components/app_spec.js index 30541ba68a5..844faff64a1 100644 --- a/spec/frontend/error_tracking_settings/components/app_spec.js +++ b/spec/frontend/error_tracking_settings/components/app_spec.js @@ -1,7 +1,8 @@ -import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui'; +import { GlFormRadioGroup, GlFormRadio, GlFormInputGroup } from '@gitlab/ui'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import Vuex from 'vuex'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { TEST_HOST } from 'helpers/test_constants'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import ErrorTrackingSettings from '~/error_tracking_settings/components/app.vue'; @@ -12,6 +13,8 @@ import createStore from '~/error_tracking_settings/store'; const localVue = createLocalVue(); localVue.use(Vuex); +const TEST_GITLAB_DSN = 'https://gitlab.example.com/123456'; + describe('error tracking settings app', () => { let store; let wrapper; @@ -29,6 +32,10 @@ describe('error tracking settings app', () => { initialProject: null, listProjectsEndpoint: TEST_HOST, operationsSettingsEndpoint: TEST_HOST, + gitlabDsn: TEST_GITLAB_DSN, + }, + stubs: { + GlFormInputGroup, // we need this non-shallow to query for a component within a slot }, }), ); @@ -41,6 +48,12 @@ describe('error tracking settings app', () => { findBackendSettingsRadioGroup().findAllComponents(GlFormRadio); const findElementWithText = (wrappers, text) => wrappers.filter((item) => item.text() === text); const findSentrySettings = () => wrapper.findByTestId('sentry-setting-form'); + const findDsnSettings = () => wrapper.findByTestId('gitlab-dsn-setting-form'); + + const enableGitLabErrorTracking = async () => { + findBackendSettingsRadioGroup().vm.$emit('change', true); + await nextTick(); + }; beforeEach(() => { store = createStore(); @@ -93,17 +106,35 @@ describe('error tracking settings app', () => { expect(findElementWithText(findBackendSettingsRadioButtons(), 'GitLab')).toHaveLength(1); }); - it('toggles the sentry-settings section when sentry is selected as a tracking-backend', async () => { + it('hides the Sentry settings when GitLab is selected as a tracking-backend', async () => { expect(findSentrySettings().exists()).toBe(true); - // set the "integrated" setting to "true" - findBackendSettingsRadioGroup().vm.$emit('change', true); - - await nextTick(); + await enableGitLabErrorTracking(); expect(findSentrySettings().exists()).toBe(false); }); + describe('GitLab DSN section', () => { + it('is visible when GitLab is selected as a tracking-backend and DSN is present', async () => { + expect(findDsnSettings().exists()).toBe(false); + + await enableGitLabErrorTracking(); + + expect(findDsnSettings().exists()).toBe(true); + }); + + it('contains copy-to-clipboard functionality for the GitLab DSN string', async () => { + await enableGitLabErrorTracking(); + + const clipBoardInput = findDsnSettings().findComponent(GlFormInputGroup); + const clipBoardButton = findDsnSettings().findComponent(ClipboardButton); + + expect(clipBoardInput.props('value')).toBe(TEST_GITLAB_DSN); + expect(clipBoardInput.attributes('readonly')).toBeTruthy(); + expect(clipBoardButton.props('text')).toBe(TEST_GITLAB_DSN); + }); + }); + it.each([true, false])( 'calls the `updateIntegrated` action when the setting changes to `%s`', (integrated) => { diff --git a/spec/frontend/experimentation/utils_spec.js b/spec/frontend/experimentation/utils_spec.js index 999bed1ffbd..de060f5eb8c 100644 --- a/spec/frontend/experimentation/utils_spec.js +++ b/spec/frontend/experimentation/utils_spec.js @@ -23,20 +23,6 @@ describe('experiment Utilities', () => { }); }); - describe('getExperimentContexts', () => { - describe.each` - gon | input | output - ${[TEST_KEY, '_data_']} | ${[TEST_KEY]} | ${[{ schema: TRACKING_CONTEXT_SCHEMA, data: { variant: '_data_' } }]} - ${[]} | ${[TEST_KEY]} | ${[]} - `('with input=$input and gon=$gon', ({ gon, input, output }) => { - assignGitlabExperiment(...gon); - - it(`returns ${output}`, () => { - expect(experimentUtils.getExperimentContexts(...input)).toEqual(output); - }); - }); - }); - describe('getAllExperimentContexts', () => { const schema = TRACKING_CONTEXT_SCHEMA; let origGon; 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 799b567a2c0..721b7249abc 100644 --- a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js +++ b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js @@ -4,6 +4,7 @@ import MockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; import Vuex from 'vuex'; import { mockTracking } from 'helpers/tracking_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; import EditFeatureFlag from '~/feature_flags/components/edit_feature_flag.vue'; import Form from '~/feature_flags/components/form.vue'; @@ -20,7 +21,7 @@ describe('Edit feature flag form', () => { endpoint: `${TEST_HOST}/feature_flags.json`, }); - const factory = (provide = {}) => { + const factory = (provide = { searchPath: '/search' }) => { if (wrapper) { wrapper.destroy(); wrapper = null; @@ -31,7 +32,7 @@ describe('Edit feature flag form', () => { }); }; - beforeEach((done) => { + beforeEach(() => { mock = new MockAdapter(axios); mock.onGet(`${TEST_HOST}/feature_flags.json`).replyOnce(200, { id: 21, @@ -45,7 +46,8 @@ describe('Edit feature flag form', () => { destroy_path: '/h5bp/html5-boilerplate/-/feature_flags/21', }); factory(); - setImmediate(() => done()); + + return waitForPromises(); }); afterEach(() => { @@ -60,7 +62,7 @@ describe('Edit feature flag form', () => { }); it('should render the toggle', () => { - expect(wrapper.find(GlToggle).exists()).toBe(true); + expect(wrapper.findComponent(GlToggle).exists()).toBe(true); }); describe('with error', () => { @@ -80,11 +82,11 @@ describe('Edit feature flag form', () => { }); it('should render feature flag form', () => { - expect(wrapper.find(Form).exists()).toEqual(true); + expect(wrapper.findComponent(Form).exists()).toEqual(true); }); it('should track when the toggle is clicked', () => { - const toggle = wrapper.find(GlToggle); + const toggle = wrapper.findComponent(GlToggle); const spy = mockTracking('_category_', toggle.element, jest.spyOn); toggle.trigger('click'); @@ -95,7 +97,7 @@ describe('Edit feature flag form', () => { }); it('should render the toggle with a visually hidden label', () => { - expect(wrapper.find(GlToggle).props()).toMatchObject({ + expect(wrapper.findComponent(GlToggle).props()).toMatchObject({ label: 'Feature flag status', labelPosition: 'hidden', }); diff --git a/spec/frontend/filterable_list_spec.js b/spec/frontend/filterable_list_spec.js index 8c6a71abad7..556cf6f8137 100644 --- a/spec/frontend/filterable_list_spec.js +++ b/spec/frontend/filterable_list_spec.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/no-deprecated import { getJSONFixture, setHTMLFixture } from 'helpers/fixtures'; import FilterableList from '~/filterable_list'; @@ -14,6 +15,7 @@ describe('FilterableList', () => { </div> <div class="js-projects-list-holder"></div> `); + // eslint-disable-next-line import/no-deprecated getJSONFixture('static/projects.json'); form = document.querySelector('form#project-filter-form'); filter = document.querySelector('.js-projects-list-filter'); diff --git a/spec/frontend/filtered_search/dropdown_user_spec.js b/spec/frontend/filtered_search/dropdown_user_spec.js index 961587f7146..9a20fb1bae6 100644 --- a/spec/frontend/filtered_search/dropdown_user_spec.js +++ b/spec/frontend/filtered_search/dropdown_user_spec.js @@ -1,8 +1,5 @@ -import DropdownUtils from '~/filtered_search/dropdown_utils'; -// TODO: Moving this line up throws an error about `FilteredSearchDropdown` -// being undefined in test. See gitlab-org/gitlab#321476 for more info. -// eslint-disable-next-line import/order import DropdownUser from '~/filtered_search/dropdown_user'; +import DropdownUtils from '~/filtered_search/dropdown_utils'; import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer'; import IssuableFilteredTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; diff --git a/spec/frontend/droplab/constants_spec.js b/spec/frontend/filtered_search/droplab/constants_spec.js index fd48228d6a2..9c1caf90ac0 100644 --- a/spec/frontend/droplab/constants_spec.js +++ b/spec/frontend/filtered_search/droplab/constants_spec.js @@ -1,4 +1,4 @@ -import * as constants from '~/droplab/constants'; +import * as constants from '~/filtered_search/droplab/constants'; describe('constants', () => { describe('DATA_TRIGGER', () => { diff --git a/spec/frontend/droplab/drop_down_spec.js b/spec/frontend/filtered_search/droplab/drop_down_spec.js index dcdbbcd4ccf..f49dbfcf79c 100644 --- a/spec/frontend/droplab/drop_down_spec.js +++ b/spec/frontend/filtered_search/droplab/drop_down_spec.js @@ -1,6 +1,6 @@ -import { SELECTED_CLASS } from '~/droplab/constants'; -import DropDown from '~/droplab/drop_down'; -import utils from '~/droplab/utils'; +import { SELECTED_CLASS } from '~/filtered_search/droplab/constants'; +import DropDown from '~/filtered_search/droplab/drop_down'; +import utils from '~/filtered_search/droplab/utils'; describe('DropLab DropDown', () => { let testContext; diff --git a/spec/frontend/droplab/hook_spec.js b/spec/frontend/filtered_search/droplab/hook_spec.js index 0b897a570f6..0d92170cfcf 100644 --- a/spec/frontend/droplab/hook_spec.js +++ b/spec/frontend/filtered_search/droplab/hook_spec.js @@ -1,7 +1,7 @@ -import DropDown from '~/droplab/drop_down'; -import Hook from '~/droplab/hook'; +import DropDown from '~/filtered_search/droplab/drop_down'; +import Hook from '~/filtered_search/droplab/hook'; -jest.mock('~/droplab/drop_down', () => jest.fn()); +jest.mock('~/filtered_search/droplab/drop_down', () => jest.fn()); describe('Hook', () => { let testContext; diff --git a/spec/frontend/droplab/plugins/ajax_filter_spec.js b/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js index d442d5cf416..88b3fc236e4 100644 --- a/spec/frontend/droplab/plugins/ajax_filter_spec.js +++ b/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js @@ -1,4 +1,4 @@ -import AjaxFilter from '~/droplab/plugins/ajax_filter'; +import AjaxFilter from '~/filtered_search/droplab/plugins/ajax_filter'; import AjaxCache from '~/lib/utils/ajax_cache'; describe('AjaxFilter', () => { diff --git a/spec/frontend/droplab/plugins/ajax_spec.js b/spec/frontend/filtered_search/droplab/plugins/ajax_spec.js index 7c6452e8337..c968b982091 100644 --- a/spec/frontend/droplab/plugins/ajax_spec.js +++ b/spec/frontend/filtered_search/droplab/plugins/ajax_spec.js @@ -1,4 +1,4 @@ -import Ajax from '~/droplab/plugins/ajax'; +import Ajax from '~/filtered_search/droplab/plugins/ajax'; import AjaxCache from '~/lib/utils/ajax_cache'; describe('Ajax', () => { diff --git a/spec/frontend/droplab/plugins/input_setter_spec.js b/spec/frontend/filtered_search/droplab/plugins/input_setter_spec.js index eebde018fa1..811b5ca4573 100644 --- a/spec/frontend/droplab/plugins/input_setter_spec.js +++ b/spec/frontend/filtered_search/droplab/plugins/input_setter_spec.js @@ -1,4 +1,4 @@ -import InputSetter from '~/droplab/plugins/input_setter'; +import InputSetter from '~/filtered_search/droplab/plugins/input_setter'; describe('InputSetter', () => { let testContext; diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js index 7185f382fc1..8ac5b6fbea6 100644 --- a/spec/frontend/filtered_search/visual_token_value_spec.js +++ b/spec/frontend/filtered_search/visual_token_value_spec.js @@ -1,4 +1,5 @@ import { escape } from 'lodash'; +import labelData from 'test_fixtures/labels/project_labels.json'; import FilteredSearchSpecHelper from 'helpers/filtered_search_spec_helper'; import { TEST_HOST } from 'helpers/test_constants'; import DropdownUtils from '~/filtered_search/dropdown_utils'; @@ -132,15 +133,8 @@ describe('Filtered Search Visual Tokens', () => { }); describe('updateLabelTokenColor', () => { - const jsonFixtureName = 'labels/project_labels.json'; const dummyEndpoint = '/dummy/endpoint'; - let labelData; - - beforeAll(() => { - labelData = getJSONFixture(jsonFixtureName); - }); - const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken( 'label', '=', diff --git a/spec/frontend/fixtures/abuse_reports.rb b/spec/frontend/fixtures/abuse_reports.rb index f5524a10033..d8c8737b125 100644 --- a/spec/frontend/fixtures/abuse_reports.rb +++ b/spec/frontend/fixtures/abuse_reports.rb @@ -13,10 +13,6 @@ RSpec.describe Admin::AbuseReportsController, '(JavaScript fixtures)', type: :co render_views - before(:all) do - clean_frontend_fixtures('abuse_reports/') - end - before do sign_in(admin) enable_admin_mode!(admin) diff --git a/spec/frontend/fixtures/admin_users.rb b/spec/frontend/fixtures/admin_users.rb index e0fecbdb1aa..5579f50da74 100644 --- a/spec/frontend/fixtures/admin_users.rb +++ b/spec/frontend/fixtures/admin_users.rb @@ -17,10 +17,6 @@ RSpec.describe Admin::UsersController, '(JavaScript fixtures)', type: :controlle render_views - before(:all) do - clean_frontend_fixtures('admin/users') - end - it 'admin/users/new_with_internal_user_regex.html' do stub_application_setting(user_default_external: true) stub_application_setting(user_default_internal_regex: '^(?:(?!\.ext@).)*$\r?') diff --git a/spec/frontend/fixtures/analytics.rb b/spec/frontend/fixtures/analytics.rb index 6d106dce166..b6a5ea6616d 100644 --- a/spec/frontend/fixtures/analytics.rb +++ b/spec/frontend/fixtures/analytics.rb @@ -6,10 +6,6 @@ RSpec.describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do let_it_be(:value_stream_id) { 'default' } - before(:all) do - clean_frontend_fixtures('projects/analytics/value_stream_analytics/') - end - before do update_metrics create_deployment @@ -26,7 +22,7 @@ RSpec.describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do sign_in(user) end - it 'projects/analytics/value_stream_analytics/stages' do + it 'projects/analytics/value_stream_analytics/stages.json' do get(:index, params: params, format: :json) expect(response).to be_successful @@ -44,7 +40,7 @@ RSpec.describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do end Gitlab::Analytics::CycleAnalytics::DefaultStages.all.each do |stage| - it "projects/analytics/value_stream_analytics/events/#{stage[:name]}" do + it "projects/analytics/value_stream_analytics/events/#{stage[:name]}.json" do get(stage[:name], params: params, format: :json) expect(response).to be_successful @@ -62,7 +58,7 @@ RSpec.describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do sign_in(user) end - it "projects/analytics/value_stream_analytics/summary" do + it "projects/analytics/value_stream_analytics/summary.json" do get(:show, params: params, format: :json) expect(response).to be_successful diff --git a/spec/frontend/fixtures/api_markdown.rb b/spec/frontend/fixtures/api_markdown.rb index cb9a116f293..89f012a5110 100644 --- a/spec/frontend/fixtures/api_markdown.rb +++ b/spec/frontend/fixtures/api_markdown.rb @@ -21,11 +21,7 @@ RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do let(:project_wiki_page) { create(:wiki_page, wiki: project_wiki) } - fixture_subdir = 'api/markdown' - before(:all) do - clean_frontend_fixtures(fixture_subdir) - group.add_owner(user) project.add_maintainer(user) end @@ -49,7 +45,7 @@ RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do name = "#{context}_#{name}" unless context.empty? - it "#{fixture_subdir}/#{name}.json" do + it "api/markdown/#{name}.json" do api_url = case context when 'project' "/#{project.full_path}/preview_markdown" diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml index 1edb8cb3f41..45f73260887 100644 --- a/spec/frontend/fixtures/api_markdown.yml +++ b/spec/frontend/fixtures/api_markdown.yml @@ -77,6 +77,35 @@ </dd> </dl> +- name: details + markdown: |- + <details> + <summary>Apply this patch</summary> + + ```diff + diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml + index 8433efaf00c..69b12c59d46 100644 + --- a/spec/frontend/fixtures/api_markdown.yml + +++ b/spec/frontend/fixtures/api_markdown.yml + @@ -33,6 +33,13 @@ + * <ruby>漢<rt>ㄏㄢˋ</rt></ruby> + * C<sub>7</sub>H<sub>16</sub> + O<sub>2</sub> → CO<sub>2</sub> + H<sub>2</sub>O + * The **Pythagorean theorem** is often expressed as <var>a<sup>2</sup></var> + <var>b<sup>2</sup></var> = <var>c<sup>2</sup></var>.The **Pythagorean theorem** is often expressed as <var>a<sup>2</sup></var> + <var>b<sup>2</sup></var> = <var>c<sup>2</sup></var> + +- name: details + + markdown: |- + + <details> + + <summary>Apply this patch</summary> + + + + 🐶 much meta, 🐶 many patch + + 🐶 such diff, 🐶 very meme + + 🐶 wow! + + </details> + - name: link + markdown: '[GitLab](https://gitlab.com)' + - name: attachment_link + ``` + + </details> - name: link markdown: '[GitLab](https://gitlab.com)' - name: attachment_link @@ -204,3 +233,57 @@ * [x] ![Sample Audio](https://gitlab.com/1.mp3) * [x] ![Sample Audio](https://gitlab.com/2.mp3) * [x] ![Sample Video](https://gitlab.com/3.mp4) +- name: table_of_contents + markdown: |- + [[_TOC_]] + + # Lorem + + Well, that's just like... your opinion.. man. + + ## Ipsum + + ### Dolar + + # Sit amit + + ### I don't know +- name: word_break + markdown: Fernstraßen<wbr>bau<wbr>privat<wbr>finanzierungs<wbr>gesetz +- name: frontmatter_yaml + markdown: |- + --- + title: Page title + --- +- name: frontmatter_toml + markdown: |- + +++ + title = "Page title" + +++ +- name: frontmatter_json + markdown: |- + ;;; + { + "title": "Page title" + } + ;;; +- name: color_chips + markdown: |- + - `#F00` + - `#F00A` + - `#FF0000` + - `#FF0000AA` + - `RGB(0,255,0)` + - `RGB(0%,100%,0%)` + - `RGBA(0,255,0,0.3)` + - `HSL(540,70%,50%)` + - `HSLA(540,70%,50%,0.3)` +- name: math + markdown: |- + This math is inline $`a^2+b^2=c^2`$. + + This is on a separate line: + + ```math + a^2+b^2=c^2 + ``` diff --git a/spec/frontend/fixtures/api_merge_requests.rb b/spec/frontend/fixtures/api_merge_requests.rb index 7117c9a1c7a..47321fbbeaa 100644 --- a/spec/frontend/fixtures/api_merge_requests.rb +++ b/spec/frontend/fixtures/api_merge_requests.rb @@ -11,10 +11,6 @@ RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'lorem-ipsum') } let_it_be(:mr) { create(:merge_request, source_project: project) } - before(:all) do - clean_frontend_fixtures('api/merge_requests') - end - it 'api/merge_requests/get.json' do 4.times { |i| create(:merge_request, source_project: project, source_branch: "branch-#{i}") } diff --git a/spec/frontend/fixtures/api_projects.rb b/spec/frontend/fixtures/api_projects.rb index fa77ca1c0cf..eada2f8e0f7 100644 --- a/spec/frontend/fixtures/api_projects.rb +++ b/spec/frontend/fixtures/api_projects.rb @@ -11,10 +11,6 @@ RSpec.describe API::Projects, '(JavaScript fixtures)', type: :request do let(:project) { create(:project, :repository, namespace: namespace, path: 'lorem-ipsum') } let(:project_empty) { create(:project_empty_repo, namespace: namespace, path: 'lorem-ipsum-empty') } - before(:all) do - clean_frontend_fixtures('api/projects') - end - it 'api/projects/get.json' do get api("/projects/#{project.id}", admin) diff --git a/spec/frontend/fixtures/application_settings.rb b/spec/frontend/fixtures/application_settings.rb index b09bea56b94..9fa8d68e695 100644 --- a/spec/frontend/fixtures/application_settings.rb +++ b/spec/frontend/fixtures/application_settings.rb @@ -19,10 +19,6 @@ RSpec.describe Admin::ApplicationSettingsController, '(JavaScript fixtures)', ty render_views - before(:all) do - clean_frontend_fixtures('application_settings/') - end - after do remove_repository(project) end diff --git a/spec/frontend/fixtures/autocomplete.rb b/spec/frontend/fixtures/autocomplete.rb index 8983e241aa5..6215fa44e27 100644 --- a/spec/frontend/fixtures/autocomplete.rb +++ b/spec/frontend/fixtures/autocomplete.rb @@ -11,10 +11,6 @@ RSpec.describe ::AutocompleteController, '(JavaScript fixtures)', type: :control let(:project) { create(:project, namespace: group, path: 'autocomplete-project') } let(:merge_request) { create(:merge_request, source_project: project, author: user) } - before(:all) do - clean_frontend_fixtures('autocomplete/') - end - before do group.add_owner(user) sign_in(user) diff --git a/spec/frontend/fixtures/autocomplete_sources.rb b/spec/frontend/fixtures/autocomplete_sources.rb index 9ff0f959c11..74bf58cc106 100644 --- a/spec/frontend/fixtures/autocomplete_sources.rb +++ b/spec/frontend/fixtures/autocomplete_sources.rb @@ -10,10 +10,6 @@ RSpec.describe Projects::AutocompleteSourcesController, '(JavaScript fixtures)', let_it_be(:project) { create(:project, namespace: group, path: 'autocomplete-sources-project') } let_it_be(:issue) { create(:issue, project: project) } - before(:all) do - clean_frontend_fixtures('autocomplete_sources/') - end - before do group.add_owner(user) sign_in(user) diff --git a/spec/frontend/fixtures/blob.rb b/spec/frontend/fixtures/blob.rb index b112886b2ca..f90e3662e98 100644 --- a/spec/frontend/fixtures/blob.rb +++ b/spec/frontend/fixtures/blob.rb @@ -11,10 +11,6 @@ RSpec.describe Projects::BlobController, '(JavaScript fixtures)', type: :control render_views - before(:all) do - clean_frontend_fixtures('blob/') - end - before do sign_in(user) allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon') diff --git a/spec/frontend/fixtures/branches.rb b/spec/frontend/fixtures/branches.rb index f3b3633347d..828564977e0 100644 --- a/spec/frontend/fixtures/branches.rb +++ b/spec/frontend/fixtures/branches.rb @@ -9,11 +9,6 @@ RSpec.describe 'Branches (JavaScript fixtures)' do let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'branches-project') } let_it_be(:user) { project.owner } - before(:all) do - clean_frontend_fixtures('branches/') - clean_frontend_fixtures('api/branches/') - end - after(:all) do remove_repository(project) end diff --git a/spec/frontend/fixtures/clusters.rb b/spec/frontend/fixtures/clusters.rb index b37aa137504..ea883555255 100644 --- a/spec/frontend/fixtures/clusters.rb +++ b/spec/frontend/fixtures/clusters.rb @@ -12,10 +12,6 @@ RSpec.describe Projects::ClustersController, '(JavaScript fixtures)', type: :con render_views - before(:all) do - clean_frontend_fixtures('clusters/') - end - before do sign_in(user) end diff --git a/spec/frontend/fixtures/commit.rb b/spec/frontend/fixtures/commit.rb index ff62a8286fc..f9e0f604b52 100644 --- a/spec/frontend/fixtures/commit.rb +++ b/spec/frontend/fixtures/commit.rb @@ -9,11 +9,6 @@ RSpec.describe 'Commit (JavaScript fixtures)' do let_it_be(:user) { project.owner } let_it_be(:commit) { project.commit("master") } - before(:all) do - clean_frontend_fixtures('commit/') - clean_frontend_fixtures('api/commits/') - end - before do allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon') end diff --git a/spec/frontend/fixtures/deploy_keys.rb b/spec/frontend/fixtures/deploy_keys.rb index 5c24c071792..bed6c798793 100644 --- a/spec/frontend/fixtures/deploy_keys.rb +++ b/spec/frontend/fixtures/deploy_keys.rb @@ -13,10 +13,6 @@ RSpec.describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :c let(:project3) { create(:project, :internal)} let(:project4) { create(:project, :internal)} - before(:all) do - clean_frontend_fixtures('deploy_keys/') - end - before do # Using an admin for these fixtures because they are used for verifying a frontend # component that would normally get its data from `Admin::DeployKeysController` diff --git a/spec/frontend/fixtures/freeze_period.rb b/spec/frontend/fixtures/freeze_period.rb index 42762fa56f9..d9573c8000d 100644 --- a/spec/frontend/fixtures/freeze_period.rb +++ b/spec/frontend/fixtures/freeze_period.rb @@ -9,10 +9,6 @@ RSpec.describe 'Freeze Periods (JavaScript fixtures)' do let_it_be(:project) { create(:project, :repository, path: 'freeze-periods-project') } let_it_be(:user) { project.owner } - before(:all) do - clean_frontend_fixtures('api/freeze-periods/') - end - after(:all) do remove_repository(project) end diff --git a/spec/frontend/fixtures/groups.rb b/spec/frontend/fixtures/groups.rb index 42aad9f187e..ddd436b98c6 100644 --- a/spec/frontend/fixtures/groups.rb +++ b/spec/frontend/fixtures/groups.rb @@ -8,10 +8,6 @@ RSpec.describe 'Groups (JavaScript fixtures)', type: :controller do let(:user) { create(:user) } let(:group) { create(:group, name: 'frontend-fixtures-group', runners_token: 'runnerstoken:intabulasreferre')} - before(:all) do - clean_frontend_fixtures('groups/') - end - before do group.add_owner(user) sign_in(user) diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb index a027247bd0d..6519416cb9e 100644 --- a/spec/frontend/fixtures/issues.rb +++ b/spec/frontend/fixtures/issues.rb @@ -11,10 +11,6 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :contr render_views - before(:all) do - clean_frontend_fixtures('issues/') - end - before do project.add_maintainer(user) sign_in(user) diff --git a/spec/frontend/fixtures/jobs.rb b/spec/frontend/fixtures/jobs.rb index 22179c790bd..12584f38629 100644 --- a/spec/frontend/fixtures/jobs.rb +++ b/spec/frontend/fixtures/jobs.rb @@ -21,10 +21,6 @@ RSpec.describe Projects::JobsController, '(JavaScript fixtures)', type: :control render_views - before(:all) do - clean_frontend_fixtures('jobs/') - end - before do sign_in(user) end diff --git a/spec/frontend/fixtures/labels.rb b/spec/frontend/fixtures/labels.rb index d7ca2aff18c..6736baed199 100644 --- a/spec/frontend/fixtures/labels.rb +++ b/spec/frontend/fixtures/labels.rb @@ -17,10 +17,6 @@ RSpec.describe 'Labels (JavaScript fixtures)' do let!(:groub_label_space) { create(:group_label, group: group, title: 'some space', color: '#FFFFFF') } let!(:groub_label_violets) { create(:group_label, group: group, title: 'violets', color: '#0000FF') } - before(:all) do - clean_frontend_fixtures('labels/') - end - after do remove_repository(project) end diff --git a/spec/frontend/fixtures/merge_requests.rb b/spec/frontend/fixtures/merge_requests.rb index f10f96f2516..68ed2ca2359 100644 --- a/spec/frontend/fixtures/merge_requests.rb +++ b/spec/frontend/fixtures/merge_requests.rb @@ -49,10 +49,6 @@ RSpec.describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: render_views - before(:all) do - clean_frontend_fixtures('merge_requests/') - end - before do sign_in(user) allow(Discussion).to receive(:build_discussion_id).and_return(['discussionid:ceterumcenseo']) diff --git a/spec/frontend/fixtures/merge_requests_diffs.rb b/spec/frontend/fixtures/merge_requests_diffs.rb index edf1fcf3c0a..e733764f248 100644 --- a/spec/frontend/fixtures/merge_requests_diffs.rb +++ b/spec/frontend/fixtures/merge_requests_diffs.rb @@ -20,10 +20,6 @@ RSpec.describe Projects::MergeRequests::DiffsController, '(JavaScript fixtures)' render_views - before(:all) do - clean_frontend_fixtures('merge_request_diffs/') - end - before do # Create a user that matches the project.commit author # This is so that the "author" information will be populated diff --git a/spec/frontend/fixtures/metrics_dashboard.rb b/spec/frontend/fixtures/metrics_dashboard.rb index eef79825ae7..d59b01b04af 100644 --- a/spec/frontend/fixtures/metrics_dashboard.rb +++ b/spec/frontend/fixtures/metrics_dashboard.rb @@ -12,10 +12,6 @@ RSpec.describe MetricsDashboard, '(JavaScript fixtures)', type: :controller do let_it_be(:environment) { create(:environment, id: 1, project: project) } let_it_be(:params) { { environment: environment } } - before(:all) do - clean_frontend_fixtures('metrics_dashboard/') - end - controller(::ApplicationController) do include MetricsDashboard end diff --git a/spec/frontend/fixtures/pipeline_schedules.rb b/spec/frontend/fixtures/pipeline_schedules.rb index a7d43fdbe62..6389f59aa0a 100644 --- a/spec/frontend/fixtures/pipeline_schedules.rb +++ b/spec/frontend/fixtures/pipeline_schedules.rb @@ -15,10 +15,6 @@ RSpec.describe Projects::PipelineSchedulesController, '(JavaScript fixtures)', t render_views - before(:all) do - clean_frontend_fixtures('pipeline_schedules/') - end - before do sign_in(user) end diff --git a/spec/frontend/fixtures/pipelines.rb b/spec/frontend/fixtures/pipelines.rb index f695b74ec87..709e14183df 100644 --- a/spec/frontend/fixtures/pipelines.rb +++ b/spec/frontend/fixtures/pipelines.rb @@ -23,10 +23,6 @@ RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :co 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/') - end - before do sign_in(user) end diff --git a/spec/frontend/fixtures/projects.rb b/spec/frontend/fixtures/projects.rb index 7873d59dbad..3c8964d398a 100644 --- a/spec/frontend/fixtures/projects.rb +++ b/spec/frontend/fixtures/projects.rb @@ -16,10 +16,6 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do render_views - before(:all) do - clean_frontend_fixtures('projects/') - end - before do project_with_repo.add_maintainer(user) sign_in(user) @@ -57,10 +53,6 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do project_variable_populated.add_maintainer(user) end - before(:all) do - clean_frontend_fixtures('graphql/projects/access_tokens') - end - base_input_path = 'access_tokens/graphql/queries/' base_output_path = 'graphql/projects/access_tokens/' query_name = 'get_projects.query.graphql' diff --git a/spec/frontend/fixtures/projects_json.rb b/spec/frontend/fixtures/projects_json.rb index c081d4f08dc..c4de56ccfab 100644 --- a/spec/frontend/fixtures/projects_json.rb +++ b/spec/frontend/fixtures/projects_json.rb @@ -8,10 +8,6 @@ RSpec.describe 'Projects JSON endpoints (JavaScript fixtures)', type: :controlle let(:admin) { create(:admin, name: 'root') } let(:project) { create(:project, :repository) } - before(:all) do - clean_frontend_fixtures('projects_json/') - end - before do project.add_maintainer(admin) sign_in(admin) diff --git a/spec/frontend/fixtures/prometheus_service.rb b/spec/frontend/fixtures/prometheus_service.rb index c349f2a24bc..bbd938c66f6 100644 --- a/spec/frontend/fixtures/prometheus_service.rb +++ b/spec/frontend/fixtures/prometheus_service.rb @@ -12,10 +12,6 @@ RSpec.describe Projects::ServicesController, '(JavaScript fixtures)', type: :con render_views - before(:all) do - clean_frontend_fixtures('services/prometheus') - end - before do sign_in(user) end diff --git a/spec/frontend/fixtures/raw.rb b/spec/frontend/fixtures/raw.rb index 44927bd29d8..211c4e7c048 100644 --- a/spec/frontend/fixtures/raw.rb +++ b/spec/frontend/fixtures/raw.rb @@ -9,14 +9,6 @@ RSpec.describe 'Raw files', '(JavaScript fixtures)' do let(:project) { create(:project, :repository, namespace: namespace, path: 'raw-project') } let(:response) { @blob.data.force_encoding('UTF-8') } - before(:all) do - clean_frontend_fixtures('blob/notebook/') - clean_frontend_fixtures('blob/pdf/') - clean_frontend_fixtures('blob/text/') - clean_frontend_fixtures('blob/binary/') - clean_frontend_fixtures('blob/images/') - end - after do remove_repository(project) end diff --git a/spec/frontend/fixtures/releases.rb b/spec/frontend/fixtures/releases.rb index e8f259fba15..fc344472588 100644 --- a/spec/frontend/fixtures/releases.rb +++ b/spec/frontend/fixtures/releases.rb @@ -116,10 +116,6 @@ RSpec.describe 'Releases (JavaScript fixtures)' do end describe API::Releases, type: :request do - before(:all) do - clean_frontend_fixtures('api/releases/') - end - it 'api/releases/release.json' do get api("/projects/#{project.id}/releases/#{release.tag}", admin) @@ -134,10 +130,6 @@ RSpec.describe 'Releases (JavaScript fixtures)' do one_release_query_path = 'releases/graphql/queries/one_release.query.graphql' one_release_for_editing_query_path = 'releases/graphql/queries/one_release_for_editing.query.graphql' - before(:all) do - clean_frontend_fixtures('graphql/releases/') - end - it "graphql/#{all_releases_query_path}.json" do query = get_graphql_query_as_string(all_releases_query_path) diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index d5d6f534def..fa150fbf57c 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -20,10 +20,6 @@ RSpec.describe 'Runner (JavaScript fixtures)' do query_path = 'runner/graphql/' fixtures_path = 'graphql/runner/' - before(:all) do - clean_frontend_fixtures(fixtures_path) - end - after(:all) do remove_repository(project) end diff --git a/spec/frontend/fixtures/search.rb b/spec/frontend/fixtures/search.rb index 264ce7d010c..db1ef67998f 100644 --- a/spec/frontend/fixtures/search.rb +++ b/spec/frontend/fixtures/search.rb @@ -9,10 +9,6 @@ RSpec.describe SearchController, '(JavaScript fixtures)', type: :controller do let_it_be(:user) { create(:user) } - before(:all) do - clean_frontend_fixtures('search/') - end - before do sign_in(user) end diff --git a/spec/frontend/fixtures/services.rb b/spec/frontend/fixtures/services.rb index 91e6c2eb280..a8293a080a9 100644 --- a/spec/frontend/fixtures/services.rb +++ b/spec/frontend/fixtures/services.rb @@ -12,10 +12,6 @@ RSpec.describe Projects::ServicesController, '(JavaScript fixtures)', type: :con render_views - before(:all) do - clean_frontend_fixtures('services/') - end - before do sign_in(user) end diff --git a/spec/frontend/fixtures/sessions.rb b/spec/frontend/fixtures/sessions.rb index 0ef14c1d4fa..bb73bf3215c 100644 --- a/spec/frontend/fixtures/sessions.rb +++ b/spec/frontend/fixtures/sessions.rb @@ -5,10 +5,6 @@ require 'spec_helper' RSpec.describe 'Sessions (JavaScript fixtures)' do include JavaScriptFixturesHelpers - before(:all) do - clean_frontend_fixtures('sessions/') - end - describe SessionsController, '(JavaScript fixtures)', type: :controller do include DeviseHelpers diff --git a/spec/frontend/fixtures/snippet.rb b/spec/frontend/fixtures/snippet.rb index 5211d52f374..397fb3e7124 100644 --- a/spec/frontend/fixtures/snippet.rb +++ b/spec/frontend/fixtures/snippet.rb @@ -12,10 +12,6 @@ RSpec.describe SnippetsController, '(JavaScript fixtures)', type: :controller do render_views - before(:all) do - clean_frontend_fixtures('snippets/') - end - before do sign_in(user) allow(Discussion).to receive(:build_discussion_id).and_return(['discussionid:ceterumcenseo']) diff --git a/spec/frontend/fixtures/startup_css.rb b/spec/frontend/fixtures/startup_css.rb index 1bd99f5cd7f..e19a98c3bab 100644 --- a/spec/frontend/fixtures/startup_css.rb +++ b/spec/frontend/fixtures/startup_css.rb @@ -9,15 +9,15 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do render_views - before(:all) do - clean_frontend_fixtures('startup_css/') - end - shared_examples 'startup css project fixtures' do |type| let(:user) { create(:user, :admin) } let(:project) { create(:project, :public, :repository, description: 'Code and stuff', creator: user) } before do + # We want vNext badge to be included and com/canary don't remove/hide any other elements. + # This is why we're turning com and canary on by default for now. + allow(Gitlab).to receive(:com?).and_return(true) + allow(Gitlab).to receive(:canary?).and_return(true) sign_in(user) end diff --git a/spec/frontend/fixtures/static/oauth_remember_me.html b/spec/frontend/fixtures/static/oauth_remember_me.html index c6af8129b4d..0b4d482925d 100644 --- a/spec/frontend/fixtures/static/oauth_remember_me.html +++ b/spec/frontend/fixtures/static/oauth_remember_me.html @@ -1,22 +1,21 @@ <div id="oauth-container"> -<input id="remember_me" type="checkbox"> + <input id="remember_me" type="checkbox" /> -<form method="post" action="http://example.com/"> - <button class="oauth-login twitter" type="submit"> - <span>Twitter</span> - </button> -</form> + <form method="post" action="http://example.com/"> + <button class="js-oauth-login twitter" type="submit"> + <span>Twitter</span> + </button> + </form> -<form method="post" action="http://example.com/"> - <button class="oauth-login github" type="submit"> - <span>GitHub</span> - </button> -</form> - -<form method="post" action="http://example.com/?redirect_fragment=L1"> - <button class="oauth-login facebook" type="submit"> - <span>Facebook</span> - </button> -</form> + <form method="post" action="http://example.com/"> + <button class="js-oauth-login github" type="submit"> + <span>GitHub</span> + </button> + </form> + <form method="post" action="http://example.com/?redirect_fragment=L1"> + <button class="js-oauth-login facebook" type="submit"> + <span>Facebook</span> + </button> + </form> </div> diff --git a/spec/frontend/fixtures/tags.rb b/spec/frontend/fixtures/tags.rb index 9483f0a4492..6cfa5f82efe 100644 --- a/spec/frontend/fixtures/tags.rb +++ b/spec/frontend/fixtures/tags.rb @@ -8,10 +8,6 @@ RSpec.describe 'Tags (JavaScript fixtures)' do let_it_be(:project) { create(:project, :repository, path: 'tags-project') } let_it_be(:user) { project.owner } - before(:all) do - clean_frontend_fixtures('api/tags/') - end - after(:all) do remove_repository(project) end diff --git a/spec/frontend/fixtures/timezones.rb b/spec/frontend/fixtures/timezones.rb index 261dcf5e116..157f47855ea 100644 --- a/spec/frontend/fixtures/timezones.rb +++ b/spec/frontend/fixtures/timezones.rb @@ -8,10 +8,6 @@ RSpec.describe TimeZoneHelper, '(JavaScript fixtures)' do let(:response) { @timezones.sort_by! { |tz| tz[:name] }.to_json } - before(:all) do - clean_frontend_fixtures('timezones/') - end - it 'timezones/short.json' do @timezones = timezone_data(format: :short) end diff --git a/spec/frontend/fixtures/todos.rb b/spec/frontend/fixtures/todos.rb index 985afafe50e..a0573b0b658 100644 --- a/spec/frontend/fixtures/todos.rb +++ b/spec/frontend/fixtures/todos.rb @@ -13,10 +13,6 @@ RSpec.describe 'Todos (JavaScript fixtures)' do let(:issue_2) { create(:issue, title: 'issue_2', project: project) } let!(:todo_2) { create(:todo, :done, user: user, project: project, target: issue_2, created_at: 50.hours.ago) } - before(:all) do - clean_frontend_fixtures('todos/') - end - after do remove_repository(project) end diff --git a/spec/frontend/fixtures/u2f.rb b/spec/frontend/fixtures/u2f.rb index a6a8ba7318b..96820c9ae80 100644 --- a/spec/frontend/fixtures/u2f.rb +++ b/spec/frontend/fixtures/u2f.rb @@ -7,10 +7,6 @@ RSpec.context 'U2F' do let(:user) { create(:user, :two_factor_via_u2f, otp_secret: 'otpsecret:coolkids') } - before(:all) do - clean_frontend_fixtures('u2f/') - end - before do stub_feature_flags(webauthn: false) end diff --git a/spec/frontend/fixtures/webauthn.rb b/spec/frontend/fixtures/webauthn.rb index b195fee76f0..c6e9b41b584 100644 --- a/spec/frontend/fixtures/webauthn.rb +++ b/spec/frontend/fixtures/webauthn.rb @@ -7,10 +7,6 @@ RSpec.context 'WebAuthn' do let(:user) { create(:user, :two_factor_via_webauthn, otp_secret: 'otpsecret:coolkids') } - before(:all) do - clean_frontend_fixtures('webauthn/') - end - describe SessionsController, '(JavaScript fixtures)', type: :controller do include DeviseHelpers diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 94ad7759110..eb11df2fe43 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -1,17 +1,15 @@ /* eslint no-param-reassign: "off" */ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; +import labelsFixture from 'test_fixtures/autocomplete_sources/labels.json'; import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete'; import { initEmojiMock } from 'helpers/emoji'; import '~/lib/utils/jquery_at_who'; -import { getJSONFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import AjaxCache from '~/lib/utils/ajax_cache'; import axios from '~/lib/utils/axios_utils'; -const labelsFixture = getJSONFixture('autocomplete_sources/labels.json'); - describe('GfmAutoComplete', () => { const fetchDataMock = { fetchData: jest.fn() }; let gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call(fetchDataMock); diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js index 2cbcb73ce5b..2ea2693a978 100644 --- a/spec/frontend/header_search/components/app_spec.js +++ b/spec/frontend/header_search/components/app_spec.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import HeaderSearchApp from '~/header_search/components/app.vue'; +import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue'; import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue'; import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue'; import { ENTER_KEY, ESC_KEY } from '~/lib/utils/keys'; @@ -20,6 +21,7 @@ describe('HeaderSearchApp', () => { const actionSpies = { setSearch: jest.fn(), + fetchAutocompleteOptions: jest.fn(), }; const createComponent = (initialState) => { @@ -46,6 +48,8 @@ describe('HeaderSearchApp', () => { const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu'); const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems); const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems); + const findHeaderSearchAutocompleteItems = () => + wrapper.findComponent(HeaderSearchAutocompleteItems); describe('template', () => { it('always renders Header Search Input', () => { @@ -74,11 +78,11 @@ describe('HeaderSearchApp', () => { }); describe.each` - search | showDefault | showScoped - ${null} | ${true} | ${false} - ${''} | ${true} | ${false} - ${MOCK_SEARCH} | ${false} | ${true} - `('Header Search Dropdown Items', ({ search, showDefault, showScoped }) => { + search | showDefault | showScoped | showAutocomplete + ${null} | ${true} | ${false} | ${false} + ${''} | ${true} | ${false} | ${false} + ${MOCK_SEARCH} | ${false} | ${true} | ${true} + `('Header Search Dropdown Items', ({ search, showDefault, showScoped, showAutocomplete }) => { describe(`when search is ${search}`, () => { beforeEach(() => { createComponent({ search }); @@ -93,6 +97,10 @@ describe('HeaderSearchApp', () => { it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => { expect(findHeaderSearchScopedItems().exists()).toBe(showScoped); }); + + it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Dropdown Items`, () => { + expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete); + }); }); }); }); @@ -139,12 +147,18 @@ describe('HeaderSearchApp', () => { }); }); - it('calls setSearch when search input event is fired', async () => { - findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH); + describe('onInput', () => { + beforeEach(() => { + findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH); + }); - await wrapper.vm.$nextTick(); + it('calls setSearch with search term', () => { + expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH); + }); - expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH); + it('calls fetchAutocompleteOptions', () => { + expect(actionSpies.fetchAutocompleteOptions).toHaveBeenCalled(); + }); }); it('submits a search onKey-Enter', async () => { diff --git a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js new file mode 100644 index 00000000000..6b84e63989d --- /dev/null +++ b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js @@ -0,0 +1,108 @@ +import { GlDropdownItem, GlLoadingIcon, GlAvatar } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue'; +import { + GROUPS_CATEGORY, + LARGE_AVATAR_PX, + PROJECTS_CATEGORY, + SMALL_AVATAR_PX, +} from '~/header_search/constants'; +import { MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data'; + +Vue.use(Vuex); + +describe('HeaderSearchAutocompleteItems', () => { + let wrapper; + + const createComponent = (initialState, mockGetters) => { + const store = new Vuex.Store({ + state: { + loading: false, + ...initialState, + }, + getters: { + autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, + ...mockGetters, + }, + }); + + wrapper = shallowMount(HeaderSearchAutocompleteItems, { + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text()); + const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); + const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findGlAvatar = () => wrapper.findComponent(GlAvatar); + + describe('template', () => { + describe('when loading is true', () => { + beforeEach(() => { + createComponent({ loading: true }); + }); + + it('renders GlLoadingIcon', () => { + expect(findGlLoadingIcon().exists()).toBe(true); + }); + + it('does not render autocomplete options', () => { + expect(findDropdownItems()).toHaveLength(0); + }); + }); + + describe('when loading is false', () => { + beforeEach(() => { + createComponent({ loading: false }); + }); + + it('does not render GlLoadingIcon', () => { + expect(findGlLoadingIcon().exists()).toBe(false); + }); + + describe('Dropdown items', () => { + it('renders item for each option in autocomplete option', () => { + expect(findDropdownItems()).toHaveLength(MOCK_AUTOCOMPLETE_OPTIONS.length); + }); + + it('renders titles correctly', () => { + const expectedTitles = MOCK_AUTOCOMPLETE_OPTIONS.map((o) => o.label); + expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); + }); + + it('renders links correctly', () => { + const expectedLinks = MOCK_AUTOCOMPLETE_OPTIONS.map((o) => o.url); + expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); + }); + }); + describe.each` + item | showAvatar | avatarSize + ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} + ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: '/123' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} + ${{ data: [{ category: 'Help', avatar_url: '' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} + ${{ data: [{ category: 'Settings' }] }} | ${false} | ${false} + `('GlAvatar', ({ item, showAvatar, avatarSize }) => { + describe(`when category is ${item.data[0].category} and avatar_url is ${item.data[0].avatar_url}`, () => { + beforeEach(() => { + createComponent({}, { autocompleteGroupedSearchOptions: () => [item] }); + }); + + it(`should${showAvatar ? '' : ' not'} render`, () => { + expect(findGlAvatar().exists()).toBe(showAvatar); + }); + + it(`should set avatarSize to ${avatarSize}`, () => { + expect(findGlAvatar().exists() && findGlAvatar().attributes('size')).toBe(avatarSize); + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/header_search/mock_data.js b/spec/frontend/header_search/mock_data.js index 5963ad9c279..915b3a4a678 100644 --- a/spec/frontend/header_search/mock_data.js +++ b/spec/frontend/header_search/mock_data.js @@ -19,6 +19,8 @@ export const MOCK_MR_PATH = '/dashboard/merge_requests'; export const MOCK_ALL_PATH = '/'; +export const MOCK_AUTOCOMPLETE_PATH = '/autocomplete'; + export const MOCK_PROJECT = { id: 123, name: 'MockProject', @@ -81,3 +83,70 @@ export const MOCK_SCOPED_SEARCH_OPTIONS = [ url: MOCK_ALL_PATH, }, ]; + +export const MOCK_AUTOCOMPLETE_OPTIONS = [ + { + category: 'Projects', + id: 1, + label: 'MockProject1', + url: 'project/1', + }, + { + category: 'Projects', + id: 2, + label: 'MockProject2', + url: 'project/2', + }, + { + category: 'Groups', + id: 1, + label: 'MockGroup1', + url: 'group/1', + }, + { + category: 'Help', + label: 'GitLab Help', + url: 'help/gitlab', + }, +]; + +export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ + { + category: 'Projects', + data: [ + { + category: 'Projects', + id: 1, + label: 'MockProject1', + url: 'project/1', + }, + { + category: 'Projects', + id: 2, + label: 'MockProject2', + url: 'project/2', + }, + ], + }, + { + category: 'Groups', + data: [ + { + category: 'Groups', + id: 1, + label: 'MockGroup1', + url: 'group/1', + }, + ], + }, + { + category: 'Help', + data: [ + { + category: 'Help', + label: 'GitLab Help', + url: 'help/gitlab', + }, + ], + }, +]; diff --git a/spec/frontend/header_search/store/actions_spec.js b/spec/frontend/header_search/store/actions_spec.js index 4530df0d91c..ee2c72df77b 100644 --- a/spec/frontend/header_search/store/actions_spec.js +++ b/spec/frontend/header_search/store/actions_spec.js @@ -1,18 +1,50 @@ +import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; +import createFlash from '~/flash'; import * as actions from '~/header_search/store/actions'; import * as types from '~/header_search/store/mutation_types'; import createState from '~/header_search/store/state'; -import { MOCK_SEARCH } from '../mock_data'; +import axios from '~/lib/utils/axios_utils'; +import { MOCK_SEARCH, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data'; + +jest.mock('~/flash'); describe('Header Search Store Actions', () => { let state; + let mock; + + const flashCallback = (callCount) => { + expect(createFlash).toHaveBeenCalledTimes(callCount); + createFlash.mockClear(); + }; beforeEach(() => { state = createState({}); + mock = new MockAdapter(axios); }); afterEach(() => { state = null; + mock.restore(); + }); + + describe.each` + axiosMock | type | expectedMutations | flashCallCount + ${{ method: 'onGet', code: 200, res: MOCK_AUTOCOMPLETE_OPTIONS }} | ${'success'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_SUCCESS, payload: MOCK_AUTOCOMPLETE_OPTIONS }]} | ${0} + ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_ERROR }]} | ${1} + `('fetchAutocompleteOptions', ({ axiosMock, type, expectedMutations, flashCallCount }) => { + describe(`on ${type}`, () => { + beforeEach(() => { + mock[axiosMock.method]().replyOnce(axiosMock.code, axiosMock.res); + }); + it(`should dispatch the correct mutations`, () => { + return testAction({ + action: actions.fetchAutocompleteOptions, + state, + expectedMutations, + }).then(() => flashCallback(flashCallCount)); + }); + }); }); describe('setSearch', () => { diff --git a/spec/frontend/header_search/store/getters_spec.js b/spec/frontend/header_search/store/getters_spec.js index 2ad0a082f6a..d55db07188e 100644 --- a/spec/frontend/header_search/store/getters_spec.js +++ b/spec/frontend/header_search/store/getters_spec.js @@ -5,6 +5,7 @@ import { MOCK_SEARCH_PATH, MOCK_ISSUE_PATH, MOCK_MR_PATH, + MOCK_AUTOCOMPLETE_PATH, MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS, MOCK_SCOPED_SEARCH_OPTIONS, @@ -12,6 +13,8 @@ import { MOCK_GROUP, MOCK_ALL_PATH, MOCK_SEARCH, + MOCK_AUTOCOMPLETE_OPTIONS, + MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, } from '../mock_data'; describe('Header Search Store Getters', () => { @@ -22,6 +25,7 @@ describe('Header Search Store Getters', () => { searchPath: MOCK_SEARCH_PATH, issuesPath: MOCK_ISSUE_PATH, mrPath: MOCK_MR_PATH, + autocompletePath: MOCK_AUTOCOMPLETE_PATH, searchContext: MOCK_SEARCH_CONTEXT, ...initialState, }); @@ -56,6 +60,29 @@ describe('Header Search Store Getters', () => { }); describe.each` + project | ref | expectedPath + ${null} | ${null} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=undefined&project_ref=null`} + ${MOCK_PROJECT} | ${null} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}&project_ref=null`} + ${MOCK_PROJECT} | ${MOCK_PROJECT.id} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}&project_ref=${MOCK_PROJECT.id}`} + `('autocompleteQuery', ({ project, ref, expectedPath }) => { + describe(`when project is ${project?.name} and project ref is ${ref}`, () => { + beforeEach(() => { + createState({ + searchContext: { + project, + ref, + }, + }); + state.search = MOCK_SEARCH; + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.autocompleteQuery(state)).toBe(expectedPath); + }); + }); + }); + + describe.each` group | group_metadata | project | project_metadata | expectedPath ${null} | ${null} | ${null} | ${null} | ${MOCK_ISSUE_PATH} ${{ name: 'Test Group' }} | ${{ issues_path: 'group/path' }} | ${null} | ${null} | ${'group/path'} @@ -208,4 +235,17 @@ describe('Header Search Store Getters', () => { ); }); }); + + describe('autocompleteGroupedSearchOptions', () => { + beforeEach(() => { + createState(); + state.autocompleteOptions = MOCK_AUTOCOMPLETE_OPTIONS; + }); + + it('returns the correct grouped array', () => { + expect(getters.autocompleteGroupedSearchOptions(state)).toStrictEqual( + MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, + ); + }); + }); }); diff --git a/spec/frontend/header_search/store/mutations_spec.js b/spec/frontend/header_search/store/mutations_spec.js index 8196c06099d..7f9b7631a7e 100644 --- a/spec/frontend/header_search/store/mutations_spec.js +++ b/spec/frontend/header_search/store/mutations_spec.js @@ -1,7 +1,7 @@ import * as types from '~/header_search/store/mutation_types'; import mutations from '~/header_search/store/mutations'; import createState from '~/header_search/store/state'; -import { MOCK_SEARCH } from '../mock_data'; +import { MOCK_SEARCH, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data'; describe('Header Search Store Mutations', () => { let state; @@ -10,6 +10,33 @@ describe('Header Search Store Mutations', () => { state = createState({}); }); + describe('REQUEST_AUTOCOMPLETE', () => { + it('sets loading to true and empties autocompleteOptions array', () => { + mutations[types.REQUEST_AUTOCOMPLETE](state); + + expect(state.loading).toBe(true); + expect(state.autocompleteOptions).toStrictEqual([]); + }); + }); + + describe('RECEIVE_AUTOCOMPLETE_SUCCESS', () => { + it('sets loading to false and sets autocompleteOptions array', () => { + mutations[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, MOCK_AUTOCOMPLETE_OPTIONS); + + expect(state.loading).toBe(false); + expect(state.autocompleteOptions).toStrictEqual(MOCK_AUTOCOMPLETE_OPTIONS); + }); + }); + + describe('RECEIVE_AUTOCOMPLETE_ERROR', () => { + it('sets loading to false and empties autocompleteOptions array', () => { + mutations[types.RECEIVE_AUTOCOMPLETE_ERROR](state); + + expect(state.loading).toBe(false); + expect(state.autocompleteOptions).toStrictEqual([]); + }); + }); + describe('SET_SEARCH', () => { it('sets search to value', () => { mutations[types.SET_SEARCH](state, MOCK_SEARCH); diff --git a/spec/frontend/ide/components/jobs/detail_spec.js b/spec/frontend/ide/components/jobs/detail_spec.js index 79ac0a8122a..3634599f328 100644 --- a/spec/frontend/ide/components/jobs/detail_spec.js +++ b/spec/frontend/ide/components/jobs/detail_spec.js @@ -41,7 +41,7 @@ describe('IDE jobs detail view', () => { }); it('scrolls to bottom', () => { - expect(vm.$refs.buildTrace.scrollTo).toHaveBeenCalled(); + expect(vm.$refs.buildJobLog.scrollTo).toHaveBeenCalled(); }); it('renders job output', () => { @@ -125,15 +125,15 @@ describe('IDE jobs detail view', () => { beforeEach(() => { vm = vm.$mount(); - jest.spyOn(vm.$refs.buildTrace, 'scrollTo').mockImplementation(); + jest.spyOn(vm.$refs.buildJobLog, 'scrollTo').mockImplementation(); }); it('scrolls build trace to bottom', () => { - jest.spyOn(vm.$refs.buildTrace, 'scrollHeight', 'get').mockReturnValue(1000); + jest.spyOn(vm.$refs.buildJobLog, 'scrollHeight', 'get').mockReturnValue(1000); vm.scrollDown(); - expect(vm.$refs.buildTrace.scrollTo).toHaveBeenCalledWith(0, 1000); + expect(vm.$refs.buildJobLog.scrollTo).toHaveBeenCalledWith(0, 1000); }); }); @@ -141,26 +141,26 @@ describe('IDE jobs detail view', () => { beforeEach(() => { vm = vm.$mount(); - jest.spyOn(vm.$refs.buildTrace, 'scrollTo').mockImplementation(); + jest.spyOn(vm.$refs.buildJobLog, 'scrollTo').mockImplementation(); }); it('scrolls build trace to top', () => { vm.scrollUp(); - expect(vm.$refs.buildTrace.scrollTo).toHaveBeenCalledWith(0, 0); + expect(vm.$refs.buildJobLog.scrollTo).toHaveBeenCalledWith(0, 0); }); }); describe('scrollBuildLog', () => { beforeEach(() => { vm = vm.$mount(); - jest.spyOn(vm.$refs.buildTrace, 'scrollTo').mockImplementation(); - jest.spyOn(vm.$refs.buildTrace, 'offsetHeight', 'get').mockReturnValue(100); - jest.spyOn(vm.$refs.buildTrace, 'scrollHeight', 'get').mockReturnValue(200); + jest.spyOn(vm.$refs.buildJobLog, 'scrollTo').mockImplementation(); + jest.spyOn(vm.$refs.buildJobLog, 'offsetHeight', 'get').mockReturnValue(100); + jest.spyOn(vm.$refs.buildJobLog, 'scrollHeight', 'get').mockReturnValue(200); }); it('sets scrollPos to bottom when at the bottom', () => { - jest.spyOn(vm.$refs.buildTrace, 'scrollTop', 'get').mockReturnValue(100); + jest.spyOn(vm.$refs.buildJobLog, 'scrollTop', 'get').mockReturnValue(100); vm.scrollBuildLog(); @@ -168,7 +168,7 @@ describe('IDE jobs detail view', () => { }); it('sets scrollPos to top when at the top', () => { - jest.spyOn(vm.$refs.buildTrace, 'scrollTop', 'get').mockReturnValue(0); + jest.spyOn(vm.$refs.buildJobLog, 'scrollTop', 'get').mockReturnValue(0); vm.scrollPos = 1; vm.scrollBuildLog(); @@ -177,7 +177,7 @@ describe('IDE jobs detail view', () => { }); it('resets scrollPos when not at top or bottom', () => { - jest.spyOn(vm.$refs.buildTrace, 'scrollTop', 'get').mockReturnValue(10); + jest.spyOn(vm.$refs.buildJobLog, 'scrollTop', 'get').mockReturnValue(10); vm.scrollBuildLog(); diff --git a/spec/frontend/ide/stores/modules/commit/getters_spec.js b/spec/frontend/ide/stores/modules/commit/getters_spec.js index 7a07ed05201..1e34087b290 100644 --- a/spec/frontend/ide/stores/modules/commit/getters_spec.js +++ b/spec/frontend/ide/stores/modules/commit/getters_spec.js @@ -126,7 +126,7 @@ describe('IDE commit module getters', () => { ); expect(getters.preBuiltCommitMessage(state, null, rootState)).toBe( - 'Update test-file, index.js files', + 'Update test-file, index.js', ); }); diff --git a/spec/frontend/ide/stores/utils_spec.js b/spec/frontend/ide/stores/utils_spec.js index 79b6b66319e..a8875e0cd02 100644 --- a/spec/frontend/ide/stores/utils_spec.js +++ b/spec/frontend/ide/stores/utils_spec.js @@ -94,7 +94,7 @@ describe('Multi-file store utils', () => { { action: commitActionTypes.move, file_path: 'renamedFile', - content: null, + content: undefined, encoding: 'text', last_commit_id: undefined, previous_path: 'prevPath', diff --git a/spec/frontend/import_entities/components/pagination_bar_spec.js b/spec/frontend/import_entities/components/pagination_bar_spec.js new file mode 100644 index 00000000000..163ce11a8db --- /dev/null +++ b/spec/frontend/import_entities/components/pagination_bar_spec.js @@ -0,0 +1,92 @@ +import { GlPagination, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import PaginationBar from '~/import_entities/components/pagination_bar.vue'; +import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; + +describe('Pagination bar', () => { + const DEFAULT_PROPS = { + pageInfo: { + total: 50, + page: 1, + perPage: 20, + }, + itemsCount: 17, + }; + let wrapper; + + const createComponent = (propsData) => { + wrapper = mount(PaginationBar, { + propsData: { + ...DEFAULT_PROPS, + ...propsData, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('events', () => { + beforeEach(() => { + createComponent(); + }); + + it('emits set-page event when page is selected', () => { + const NEXT_PAGE = 3; + // PaginationLinks uses prop instead of event for handling page change + // So we go one level deep to test this + wrapper + .findComponent(PaginationLinks) + .findComponent(GlPagination) + .vm.$emit('input', NEXT_PAGE); + expect(wrapper.emitted('set-page')).toEqual([[NEXT_PAGE]]); + }); + + it('emits set-page-size event when page size is selected', () => { + const firstItemInPageSizeDropdown = wrapper.findComponent(GlDropdownItem); + firstItemInPageSizeDropdown.vm.$emit('click'); + + const [emittedPageSizeChange] = wrapper.emitted('set-page-size')[0]; + expect(firstItemInPageSizeDropdown.text()).toMatchInterpolatedText( + `${emittedPageSizeChange} items per page`, + ); + }); + }); + + it('renders current page size', () => { + const CURRENT_PAGE_SIZE = 40; + + createComponent({ + pageInfo: { + ...DEFAULT_PROPS.pageInfo, + perPage: CURRENT_PAGE_SIZE, + }, + }); + + expect(wrapper.find(GlDropdown).find('button').text()).toMatchInterpolatedText( + `${CURRENT_PAGE_SIZE} items per page`, + ); + }); + + it('renders current page information', () => { + createComponent(); + + expect(wrapper.find('[data-testid="information"]').text()).toMatchInterpolatedText( + 'Showing 1 - 17 of 50', + ); + }); + + it('renders current page information when total count is over 1000', () => { + createComponent({ + pageInfo: { + ...DEFAULT_PROPS.pageInfo, + total: 1200, + }, + }); + + expect(wrapper.find('[data-testid="information"]').text()).toMatchInterpolatedText( + 'Showing 1 - 17 of 1000+', + ); + }); +}); diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index ff602327592..0a9cbadb249 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -11,7 +11,7 @@ import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_field import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue'; import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; -import { integrationLevels } from '~/integrations/edit/constants'; +import { integrationLevels } from '~/integrations/constants'; import { createStore } from '~/integrations/edit/store'; describe('IntegrationForm', () => { diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js index 2860d3cc37a..119afbfecfe 100644 --- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js @@ -1,6 +1,7 @@ import { GlFormCheckbox, GlFormInput } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { GET_JIRA_ISSUE_TYPES_EVENT } from '~/integrations/constants'; import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue'; import eventHub from '~/integrations/edit/event_hub'; import { createStore } from '~/integrations/edit/store'; @@ -207,7 +208,7 @@ describe('JiraIssuesFields', () => { await setEnableCheckbox(true); await findJiraForVulnerabilities().vm.$emit('request-get-issue-types'); - expect(eventHubEmitSpy).toHaveBeenCalledWith('getJiraIssueTypes'); + expect(eventHubEmitSpy).toHaveBeenCalledWith(GET_JIRA_ISSUE_TYPES_EVENT); }); }); }); diff --git a/spec/frontend/integrations/edit/components/override_dropdown_spec.js b/spec/frontend/integrations/edit/components/override_dropdown_spec.js index eb43d940f5e..90facaff1f9 100644 --- a/spec/frontend/integrations/edit/components/override_dropdown_spec.js +++ b/spec/frontend/integrations/edit/components/override_dropdown_spec.js @@ -2,7 +2,7 @@ import { GlDropdown, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; -import { integrationLevels, overrideDropdownDescriptions } from '~/integrations/edit/constants'; +import { integrationLevels, overrideDropdownDescriptions } from '~/integrations/constants'; import { createStore } from '~/integrations/edit/store'; describe('OverrideDropdown', () => { diff --git a/spec/frontend/integrations/integration_settings_form_spec.js b/spec/frontend/integrations/integration_settings_form_spec.js index cbb2ef380ba..f8f3f0fd318 100644 --- a/spec/frontend/integrations/integration_settings_form_spec.js +++ b/spec/frontend/integrations/integration_settings_form_spec.js @@ -23,7 +23,7 @@ describe('IntegrationSettingsForm', () => { it('should initialize form element refs on class object', () => { // Form Reference expect(integrationSettingsForm.$form).toBeDefined(); - expect(integrationSettingsForm.$form.prop('nodeName')).toEqual('FORM'); + expect(integrationSettingsForm.$form.nodeName).toBe('FORM'); expect(integrationSettingsForm.formActive).toBeDefined(); }); @@ -43,14 +43,14 @@ describe('IntegrationSettingsForm', () => { integrationSettingsForm.formActive = true; integrationSettingsForm.toggleServiceState(); - expect(integrationSettingsForm.$form.attr('novalidate')).not.toBeDefined(); + expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBe(null); }); it('should set `novalidate` attribute to form when called with `false`', () => { integrationSettingsForm.formActive = false; integrationSettingsForm.toggleServiceState(); - expect(integrationSettingsForm.$form.attr('novalidate')).toBeDefined(); + expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBeDefined(); }); }); @@ -67,8 +67,7 @@ describe('IntegrationSettingsForm', () => { integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); integrationSettingsForm.init(); - // eslint-disable-next-line no-jquery/no-serialize - formData = integrationSettingsForm.$form.serialize(); + formData = new FormData(integrationSettingsForm.$form); }); afterEach(() => { @@ -145,8 +144,7 @@ describe('IntegrationSettingsForm', () => { integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); integrationSettingsForm.init(); - // eslint-disable-next-line no-jquery/no-serialize - formData = integrationSettingsForm.$form.serialize(); + formData = new FormData(integrationSettingsForm.$form); }); afterEach(() => { diff --git a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js index dbed236d7df..ae89d05cead 100644 --- a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js +++ b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js @@ -1,16 +1,14 @@ -import { GlTable, GlLink, GlPagination } from '@gitlab/ui'; +import { GlTable, GlLink, GlPagination, GlAlert } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { shallowMount, mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import { DEFAULT_PER_PAGE } from '~/api'; -import createFlash from '~/flash'; import IntegrationOverrides from '~/integrations/overrides/components/integration_overrides.vue'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; -jest.mock('~/flash'); - const mockOverrides = Array(DEFAULT_PER_PAGE * 3) .fill(1) .map((_, index) => ({ @@ -62,6 +60,7 @@ describe('IntegrationOverrides', () => { text: link.text(), }; }); + const findAlert = () => wrapper.findComponent(GlAlert); describe('while loading', () => { it('sets GlTable `busy` attribute to `true`', () => { @@ -104,18 +103,26 @@ describe('IntegrationOverrides', () => { describe('when request fails', () => { beforeEach(async () => { + jest.spyOn(Sentry, 'captureException'); mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.INTERNAL_SERVER_ERROR); + createComponent(); await waitForPromises(); }); - it('calls createFlash', () => { - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ - message: IntegrationOverrides.i18n.defaultErrorMessage, - captureError: true, - error: expect.any(Error), - }); + it('displays error alert', () => { + const alert = findAlert(); + expect(alert.exists()).toBe(true); + expect(alert.text()).toBe(IntegrationOverrides.i18n.defaultErrorMessage); + }); + + it('hides overrides table', () => { + const table = findGlTable(); + expect(table.exists()).toBe(false); + }); + + it('captures exception in Sentry', () => { + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); }); }); 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 95b1c55b82d..8c3c549a5eb 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -242,7 +242,7 @@ describe('InviteMembersModal', () => { }; const expectedEmailRestrictedError = - "email 'email@example.com' does not match the allowed domains: example1.org"; + "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups."; const expectedSyntaxError = 'email contains an invalid email address'; it('calls the API with the expected focus data when an areas_of_focus checkbox is clicked', () => { @@ -421,7 +421,7 @@ describe('InviteMembersModal', () => { await waitForPromises(); expect(membersFormGroupInvalidFeedback()).toBe( - "root: User email 'admin@example.com' does not match the allowed domain of example2.com", + "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.", ); expect(findMembersSelect().props('validationState')).toBe(false); }); diff --git a/spec/frontend/invite_members/mock_data/api_responses.js b/spec/frontend/invite_members/mock_data/api_responses.js index 79b56a33708..dd84b4fd78f 100644 --- a/spec/frontend/invite_members/mock_data/api_responses.js +++ b/spec/frontend/invite_members/mock_data/api_responses.js @@ -9,7 +9,7 @@ const INVITATIONS_API_ERROR_EMAIL_INVALID = { const INVITATIONS_API_EMAIL_RESTRICTED = { message: { 'email@example.com': - "Invite email 'email@example.com' does not match the allowed domains: example1.org", + "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.", }, status: 'error', }; @@ -17,9 +17,9 @@ const INVITATIONS_API_EMAIL_RESTRICTED = { const INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED = { message: { 'email@example.com': - "Invite email email 'email@example.com' does not match the allowed domains: example1.org", + "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.", 'email4@example.com': - "Invite email email 'email4@example.com' does not match the allowed domains: example1.org", + "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check the Domain denylist.", }, status: 'error', }; @@ -36,7 +36,11 @@ const MEMBERS_API_MEMBER_ALREADY_EXISTS = { }; const MEMBERS_API_SINGLE_USER_RESTRICTED = { - message: { user: ["email 'email@example.com' does not match the allowed domains: example1.org"] }, + message: { + user: [ + "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.", + ], + }, }; const MEMBERS_API_SINGLE_USER_ACCESS_LEVEL = { @@ -49,7 +53,7 @@ const MEMBERS_API_SINGLE_USER_ACCESS_LEVEL = { const MEMBERS_API_MULTIPLE_USERS_RESTRICTED = { message: - "root: User email 'admin@example.com' does not match the allowed domain of example2.com and user18: User email 'user18@example.org' does not match the allowed domain of example2.com", + "root: The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups. and user18: The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check the Domain denylist. and john_doe31: The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Email restrictions for sign-ups.", status: 'error', }; diff --git a/spec/frontend/invite_members/utils/response_message_parser_spec.js b/spec/frontend/invite_members/utils/response_message_parser_spec.js index 3c88b5a2418..e2cc87c8547 100644 --- a/spec/frontend/invite_members/utils/response_message_parser_spec.js +++ b/spec/frontend/invite_members/utils/response_message_parser_spec.js @@ -2,18 +2,20 @@ import { responseMessageFromSuccess, responseMessageFromError, } from '~/invite_members/utils/response_message_parser'; +import { membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses'; describe('Response message parser', () => { - const expectedMessage = 'expected display message'; + const expectedMessage = 'expected display and message.'; describe('parse message from successful response', () => { const exampleKeyedMsg = { 'email@example.com': expectedMessage }; + const exampleFirstPartMultiple = 'username1: expected display and message.'; const exampleUserMsgMultiple = - ' and username1: id not found and username2: email is restricted'; + ' and username2: id not found and restricted email. and username3: email is restricted.'; it.each([ [[{ data: { message: expectedMessage } }]], - [[{ data: { message: expectedMessage + exampleUserMsgMultiple } }]], + [[{ data: { message: exampleFirstPartMultiple + exampleUserMsgMultiple } }]], [[{ data: { error: expectedMessage } }]], [[{ data: { message: [expectedMessage] } }]], [[{ data: { message: exampleKeyedMsg } }]], @@ -33,4 +35,24 @@ describe('Response message parser', () => { expect(responseMessageFromError(errorResponse)).toBe(expectedMessage); }); }); + + describe('displaying only the first error when a response has messages for multiple users', () => { + const expected = + "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups."; + + it.each([ + [[{ data: membersApiResponse.MULTIPLE_USERS_RESTRICTED }]], + [[{ data: invitationsApiResponse.MULTIPLE_EMAIL_RESTRICTED }]], + [[{ data: invitationsApiResponse.EMAIL_RESTRICTED }]], + ])(`returns "${expectedMessage}" from success response: %j`, (restrictedResponse) => { + expect(responseMessageFromSuccess(restrictedResponse)).toBe(expected); + }); + + it.each([[{ response: { data: membersApiResponse.SINGLE_USER_RESTRICTED } }]])( + `returns "${expectedMessage}" from error response: %j`, + (singleRestrictedResponse) => { + expect(responseMessageFromError(singleRestrictedResponse)).toBe(expected); + }, + ); + }); }); diff --git a/spec/frontend/issuable/components/csv_export_modal_spec.js b/spec/frontend/issuable/components/csv_export_modal_spec.js index 34094d22e68..ad4abda6912 100644 --- a/spec/frontend/issuable/components/csv_export_modal_spec.js +++ b/spec/frontend/issuable/components/csv_export_modal_spec.js @@ -61,11 +61,6 @@ describe('CsvExportModal', () => { expect(wrapper.text()).toContain('10 issues selected'); expect(findIcon().exists()).toBe(true); }); - - it("doesn't display the info text when issuableCount is -1", () => { - wrapper = createComponent({ props: { issuableCount: -1 } }); - expect(wrapper.text()).not.toContain('issues selected'); - }); }); describe('email info text', () => { diff --git a/spec/frontend/issuable/components/csv_import_modal_spec.js b/spec/frontend/issuable/components/csv_import_modal_spec.js index 0c88b6b1283..307323ef07a 100644 --- a/spec/frontend/issuable/components/csv_import_modal_spec.js +++ b/spec/frontend/issuable/components/csv_import_modal_spec.js @@ -17,7 +17,6 @@ describe('CsvImportModal', () => { ...props, }, provide: { - issuableType: 'issues', ...injectedProperties, }, stubs: { @@ -43,9 +42,9 @@ describe('CsvImportModal', () => { const findAuthenticityToken = () => new FormData(findForm().element).get('authenticity_token'); describe('template', () => { - it('displays modal title', () => { + it('passes correct title props to modal', () => { wrapper = createComponent(); - expect(findModal().text()).toContain('Import issues'); + expect(findModal().props('title')).toContain('Import issues'); }); it('displays a note about the maximum allowed file size', () => { @@ -73,7 +72,7 @@ describe('CsvImportModal', () => { }); it('submits the form when the primary action is clicked', () => { - findPrimaryButton().trigger('click'); + findModal().vm.$emit('primary'); expect(formSubmitSpy).toHaveBeenCalled(); }); diff --git a/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js index 173d12757e3..ff6922989cb 100644 --- a/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js +++ b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js @@ -1,5 +1,6 @@ import { mount, shallowMount } from '@vue/test-utils'; import AddIssuableForm from '~/related_issues/components/add_issuable_form.vue'; +import IssueToken from '~/related_issues/components/issue_token.vue'; import { issuableTypesMap, linkedIssueTypesMap, PathIdSeparator } from '~/related_issues/constants'; const issuable1 = { @@ -22,7 +23,7 @@ const issuable2 = { const pathIdSeparator = PathIdSeparator.Issue; -const findFormInput = (wrapper) => wrapper.find('.js-add-issuable-form-input').element; +const findFormInput = (wrapper) => wrapper.find('input').element; const findRadioInput = (inputs, value) => inputs.filter((input) => input.element.value === value)[0]; @@ -105,11 +106,11 @@ describe('AddIssuableForm', () => { }); it('should put input value in place', () => { - expect(findFormInput(wrapper).value).toEqual(inputValue); + expect(findFormInput(wrapper).value).toBe(inputValue); }); it('should render pending issuables items', () => { - expect(wrapper.findAll('.js-add-issuable-form-token-list-item').length).toEqual(2); + expect(wrapper.findAllComponents(IssueToken)).toHaveLength(2); }); it('should not have disabled submit button', () => { diff --git a/spec/frontend/issuable_form_spec.js b/spec/frontend/issuable_form_spec.js index bc7a87eb65c..c77fde4261e 100644 --- a/spec/frontend/issuable_form_spec.js +++ b/spec/frontend/issuable_form_spec.js @@ -20,16 +20,13 @@ describe('IssuableForm', () => { describe('removeWip', () => { it.each` prefix - ${'drAft '} ${'draFT: '} ${' [DRaft] '} ${'drAft:'} ${'[draFT]'} - ${' dRaFt - '} - ${'dRaFt - '} ${'(draft) '} ${' (DrafT)'} - ${'draft draft - draft: [draft] (draft)'} + ${'draft: [draft] (draft)'} `('removes "$prefix" from the beginning of the title', ({ prefix }) => { instance.titleField.val(`${prefix}The Issuable's Title Value`); @@ -48,4 +45,18 @@ describe('IssuableForm', () => { expect(instance.titleField.val()).toBe("Draft: The Issuable's Title Value"); }); }); + + describe('workInProgress', () => { + it.each` + title | expected + ${'draFT: something is happening'} | ${true} + ${'draft something is happening'} | ${false} + ${'something is happening to drafts'} | ${false} + ${'something is happening'} | ${false} + `('returns $expected with "$title"', ({ title, expected }) => { + instance.titleField.val(title); + + expect(instance.workInProgress()).toBe(expected); + }); + }); }); diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/issuable_list/components/issuable_item_spec.js index ea36d59ff83..ac3bf7f3269 100644 --- a/spec/frontend/issuable_list/components/issuable_item_spec.js +++ b/spec/frontend/issuable_list/components/issuable_item_spec.js @@ -1,4 +1,4 @@ -import { GlLink, GlLabel, GlIcon, GlFormCheckbox } from '@gitlab/ui'; +import { GlLink, GlLabel, GlIcon, GlFormCheckbox, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { useFakeDate } from 'helpers/fake_date'; import IssuableItem from '~/issuable_list/components/issuable_item.vue'; @@ -16,6 +16,9 @@ const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable, slots showCheckbox: false, }, slots, + stubs: { + GlSprintf, + }, }); const MOCK_GITLAB_URL = 'http://0.0.0.0:3000'; @@ -135,13 +138,6 @@ describe('IssuableItem', () => { }); }); - describe('createdAt', () => { - it('returns string containing timeago string based on `issuable.createdAt`', () => { - expect(wrapper.vm.createdAt).toContain('created'); - expect(wrapper.vm.createdAt).toContain('ago'); - }); - }); - describe('updatedAt', () => { it('returns string containing timeago string based on `issuable.updatedAt`', () => { expect(wrapper.vm.updatedAt).toContain('updated'); @@ -449,8 +445,7 @@ describe('IssuableItem', () => { it('renders issuable updatedAt info', () => { const updatedAtEl = wrapper.find('[data-testid="issuable-updated-at"]'); - expect(updatedAtEl.exists()).toBe(true); - expect(updatedAtEl.find('span').attributes('title')).toBe('Sep 10, 2020 11:41am UTC'); + expect(updatedAtEl.attributes('title')).toBe('Sep 10, 2020 11:41am UTC'); expect(updatedAtEl.text()).toBe(wrapper.vm.updatedAt); }); diff --git a/spec/frontend/issuable_suggestions/components/item_spec.js b/spec/frontend/issuable_suggestions/components/item_spec.js index 39083b3d8fb..45f96103e3e 100644 --- a/spec/frontend/issuable_suggestions/components/item_spec.js +++ b/spec/frontend/issuable_suggestions/components/item_spec.js @@ -6,10 +6,10 @@ import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_ima import mockData from '../mock_data'; describe('Issuable suggestions suggestion component', () => { - let vm; + let wrapper; function createComponent(suggestion = {}) { - vm = shallowMount(Suggestion, { + wrapper = shallowMount(Suggestion, { propsData: { suggestion: { ...mockData(), @@ -19,37 +19,40 @@ describe('Issuable suggestions suggestion component', () => { }); } + const findLink = () => wrapper.findComponent(GlLink); + const findAuthorLink = () => wrapper.findAll(GlLink).at(1); + const findIcon = () => wrapper.findComponent(GlIcon); + const findTooltip = () => wrapper.findComponent(GlTooltip); + const findUserAvatar = () => wrapper.findComponent(UserAvatarImage); + afterEach(() => { - vm.destroy(); + wrapper.destroy(); }); it('renders title', () => { createComponent(); - expect(vm.text()).toContain('Test issue'); + expect(wrapper.text()).toContain('Test issue'); }); it('renders issue link', () => { createComponent(); - const link = vm.find(GlLink); - - expect(link.attributes('href')).toBe(`${TEST_HOST}/test/issue/1`); + expect(findLink().attributes('href')).toBe(`${TEST_HOST}/test/issue/1`); }); it('renders IID', () => { createComponent(); - expect(vm.text()).toContain('#1'); + expect(wrapper.text()).toContain('#1'); }); describe('opened state', () => { it('renders icon', () => { createComponent(); - const icon = vm.find(GlIcon); - - expect(icon.props('name')).toBe('issue-open-m'); + expect(findIcon().props('name')).toBe('issue-open-m'); + expect(findIcon().attributes('class')).toMatch('gl-text-green-500'); }); it('renders created timeago', () => { @@ -57,10 +60,8 @@ describe('Issuable suggestions suggestion component', () => { closedAt: '', }); - const tooltip = vm.find(GlTooltip); - - expect(tooltip.find('.d-block').text()).toContain('Opened'); - expect(tooltip.text()).toContain('3 days ago'); + expect(findTooltip().text()).toContain('Opened'); + expect(findTooltip().text()).toContain('3 days ago'); }); }); @@ -70,18 +71,15 @@ describe('Issuable suggestions suggestion component', () => { state: 'closed', }); - const icon = vm.find(GlIcon); - - expect(icon.props('name')).toBe('issue-close'); + expect(findIcon().props('name')).toBe('issue-close'); + expect(findIcon().attributes('class')).toMatch('gl-text-blue-500'); }); it('renders closed timeago', () => { createComponent(); - const tooltip = vm.find(GlTooltip); - - expect(tooltip.find('.d-block').text()).toContain('Opened'); - expect(tooltip.text()).toContain('1 day ago'); + expect(findTooltip().text()).toContain('Opened'); + expect(findTooltip().text()).toContain('1 day ago'); }); }); @@ -89,18 +87,14 @@ describe('Issuable suggestions suggestion component', () => { it('renders author info', () => { createComponent(); - const link = vm.findAll(GlLink).at(1); - - expect(link.text()).toContain('Author Name'); - expect(link.text()).toContain('@author.username'); + expect(findAuthorLink().text()).toContain('Author Name'); + expect(findAuthorLink().text()).toContain('@author.username'); }); it('renders author image', () => { createComponent(); - const image = vm.find(UserAvatarImage); - - expect(image.props('imgSrc')).toBe(`${TEST_HOST}/avatar`); + expect(findUserAvatar().props('imgSrc')).toBe(`${TEST_HOST}/avatar`); }); }); @@ -108,7 +102,7 @@ describe('Issuable suggestions suggestion component', () => { it('renders upvotes count', () => { createComponent(); - const count = vm.findAll('.suggestion-counts span').at(0); + const count = wrapper.findAll('.suggestion-counts span').at(0); expect(count.text()).toContain('1'); expect(count.find(GlIcon).props('name')).toBe('thumb-up'); @@ -117,7 +111,7 @@ describe('Issuable suggestions suggestion component', () => { it('renders notes count', () => { createComponent(); - const count = vm.findAll('.suggestion-counts span').at(1); + const count = wrapper.findAll('.suggestion-counts span').at(1); expect(count.text()).toContain('2'); expect(count.find(GlIcon).props('name')).toBe('comment'); @@ -130,10 +124,9 @@ describe('Issuable suggestions suggestion component', () => { confidential: true, }); - const icon = vm.find(GlIcon); - - expect(icon.props('name')).toBe('eye-slash'); - expect(icon.attributes('title')).toBe('Confidential'); + expect(findIcon().props('name')).toBe('eye-slash'); + expect(findIcon().attributes('class')).toMatch('gl-text-orange-500'); + expect(findIcon().attributes('title')).toBe('Confidential'); }); }); }); diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js index 8d79a5eed35..6b443062f12 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -24,6 +24,7 @@ import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants'; import IssuesListApp from '~/issues_list/components/issues_list_app.vue'; +import NewIssueDropdown from '~/issues_list/components/new_issue_dropdown.vue'; import { CREATED_DESC, DUE_DATE_OVERDUE, @@ -65,6 +66,7 @@ describe('IssuesListApp component', () => { exportCsvPath: 'export/csv/path', fullPath: 'path/to/project', hasAnyIssues: true, + hasAnyProjects: true, hasBlockedIssuesFeature: true, hasIssueWeightsFeature: true, hasIterationsFeature: true, @@ -93,6 +95,7 @@ describe('IssuesListApp component', () => { const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); const findGlLink = () => wrapper.findComponent(GlLink); const findIssuableList = () => wrapper.findComponent(IssuableList); + const findNewIssueDropdown = () => wrapper.findComponent(NewIssueDropdown); const mountComponent = ({ provide = {}, @@ -190,10 +193,7 @@ describe('IssuesListApp component', () => { beforeEach(() => { setWindowLocation(search); - wrapper = mountComponent({ - provide: { isSignedIn: true }, - mountFn: mount, - }); + wrapper = mountComponent({ provide: { isSignedIn: true }, mountFn: mount }); jest.runOnlyPendingTimers(); }); @@ -208,7 +208,7 @@ describe('IssuesListApp component', () => { describe('when user is not signed in', () => { it('does not render', () => { - wrapper = mountComponent({ provide: { isSignedIn: false } }); + wrapper = mountComponent({ provide: { isSignedIn: false }, mountFn: mount }); expect(findCsvImportExportButtons().exists()).toBe(false); }); @@ -216,7 +216,7 @@ describe('IssuesListApp component', () => { describe('when in a group context', () => { it('does not render', () => { - wrapper = mountComponent({ provide: { isProject: false } }); + wrapper = mountComponent({ provide: { isProject: false }, mountFn: mount }); expect(findCsvImportExportButtons().exists()).toBe(false); }); @@ -231,7 +231,7 @@ describe('IssuesListApp component', () => { }); it('does not render when user does not have permissions', () => { - wrapper = mountComponent({ provide: { canBulkUpdate: false } }); + wrapper = mountComponent({ provide: { canBulkUpdate: false }, mountFn: mount }); expect(findGlButtons().filter((button) => button.text() === 'Edit issues')).toHaveLength(0); }); @@ -258,11 +258,25 @@ describe('IssuesListApp component', () => { }); it('does not render when user does not have permissions', () => { - wrapper = mountComponent({ provide: { showNewIssueLink: false } }); + wrapper = mountComponent({ provide: { showNewIssueLink: false }, mountFn: mount }); expect(findGlButtons().filter((button) => button.text() === 'New issue')).toHaveLength(0); }); }); + + describe('new issue split dropdown', () => { + it('does not render in a project context', () => { + wrapper = mountComponent({ provide: { isProject: true }, mountFn: mount }); + + expect(findNewIssueDropdown().exists()).toBe(false); + }); + + it('renders in a group context', () => { + wrapper = mountComponent({ provide: { isProject: false }, mountFn: mount }); + + expect(findNewIssueDropdown().exists()).toBe(true); + }); + }); }); describe('initial url params', () => { @@ -506,7 +520,7 @@ describe('IssuesListApp component', () => { beforeEach(() => { wrapper = mountComponent({ provide: { - groupEpicsPath: '', + groupPath: '', }, }); }); @@ -522,7 +536,7 @@ describe('IssuesListApp component', () => { beforeEach(() => { wrapper = mountComponent({ provide: { - groupEpicsPath: '', + groupPath: '', }, }); }); @@ -550,7 +564,7 @@ describe('IssuesListApp component', () => { provide: { isSignedIn: true, projectIterationsPath: 'project/iterations/path', - groupEpicsPath: 'group/epics/path', + groupPath: 'group/path', hasIssueWeightsFeature: true, }, }); diff --git a/spec/frontend/issues_list/components/new_issue_dropdown_spec.js b/spec/frontend/issues_list/components/new_issue_dropdown_spec.js new file mode 100644 index 00000000000..1fcaa99cf5a --- /dev/null +++ b/spec/frontend/issues_list/components/new_issue_dropdown_spec.js @@ -0,0 +1,131 @@ +import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import NewIssueDropdown from '~/issues_list/components/new_issue_dropdown.vue'; +import searchProjectsQuery from '~/issues_list/queries/search_projects.query.graphql'; +import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; +import { + emptySearchProjectsQueryResponse, + project1, + project2, + searchProjectsQueryResponse, +} from '../mock_data'; + +describe('NewIssueDropdown component', () => { + let wrapper; + + const localVue = createLocalVue(); + localVue.use(VueApollo); + + const mountComponent = ({ + search = '', + queryResponse = searchProjectsQueryResponse, + mountFn = shallowMount, + } = {}) => { + const requestHandlers = [[searchProjectsQuery, jest.fn().mockResolvedValue(queryResponse)]]; + const apolloProvider = createMockApollo(requestHandlers); + + return mountFn(NewIssueDropdown, { + localVue, + apolloProvider, + provide: { + fullPath: 'mushroom-kingdom', + }, + data() { + return { search }; + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findInput = () => wrapper.findComponent(GlSearchBoxByType); + const showDropdown = async () => { + findDropdown().vm.$emit('shown'); + await wrapper.vm.$apollo.queries.projects.refetch(); + jest.runOnlyPendingTimers(); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a split dropdown', () => { + wrapper = mountComponent(); + + expect(findDropdown().props('split')).toBe(true); + }); + + it('renders a label for the dropdown toggle button', () => { + wrapper = mountComponent(); + + expect(findDropdown().attributes('toggle-text')).toBe(NewIssueDropdown.i18n.toggleButtonLabel); + }); + + it('focuses on input when dropdown is shown', async () => { + wrapper = mountComponent({ mountFn: mount }); + + const inputSpy = jest.spyOn(findInput().vm, 'focusInput'); + + await showDropdown(); + + expect(inputSpy).toHaveBeenCalledTimes(1); + }); + + it('renders expected dropdown items', async () => { + wrapper = mountComponent({ mountFn: mount }); + + await showDropdown(); + + const listItems = wrapper.findAll('li'); + + expect(listItems.at(0).text()).toBe(project1.nameWithNamespace); + expect(listItems.at(1).text()).toBe(project2.nameWithNamespace); + }); + + it('renders `No matches found` when there are no matches', async () => { + wrapper = mountComponent({ + search: 'no matches', + queryResponse: emptySearchProjectsQueryResponse, + mountFn: mount, + }); + + await showDropdown(); + + expect(wrapper.find('li').text()).toBe(NewIssueDropdown.i18n.noMatchesFound); + }); + + describe('when no project is selected', () => { + beforeEach(() => { + wrapper = mountComponent(); + }); + + it('dropdown button is not a link', () => { + expect(findDropdown().attributes('split-href')).toBeUndefined(); + }); + + it('displays default text on the dropdown button', () => { + expect(findDropdown().props('text')).toBe(NewIssueDropdown.i18n.defaultDropdownText); + }); + }); + + describe('when a project is selected', () => { + beforeEach(async () => { + wrapper = mountComponent({ mountFn: mount }); + + await showDropdown(); + + wrapper.findComponent(GlDropdownItem).vm.$emit('click', project1); + }); + + it('dropdown button is a link', () => { + const href = joinPaths(project1.webUrl, DASH_SCOPE, 'issues/new'); + + expect(findDropdown().attributes('split-href')).toBe(href); + }); + + it('displays project name on the dropdown button', () => { + expect(findDropdown().props('text')).toBe(`New issue in ${project1.name}`); + }); + }); +}); diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js index 720f9cac986..3be256d8094 100644 --- a/spec/frontend/issues_list/mock_data.js +++ b/spec/frontend/issues_list/mock_data.js @@ -221,3 +221,37 @@ export const urlParamsWithSpecialValues = { epic_id: 'None', weight: 'None', }; + +export const project1 = { + id: 'gid://gitlab/Group/26', + name: 'Super Mario Project', + nameWithNamespace: 'Mushroom Kingdom / Super Mario Project', + webUrl: 'https://127.0.0.1:3000/mushroom-kingdom/super-mario-project', +}; + +export const project2 = { + id: 'gid://gitlab/Group/59', + name: 'Mario Kart Project', + nameWithNamespace: 'Mushroom Kingdom / Mario Kart Project', + webUrl: 'https://127.0.0.1:3000/mushroom-kingdom/mario-kart-project', +}; + +export const searchProjectsQueryResponse = { + data: { + group: { + projects: { + nodes: [project1, project2], + }, + }, + }, +}; + +export const emptySearchProjectsQueryResponse = { + data: { + group: { + projects: { + nodes: [], + }, + }, + }, +}; diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap index 891ba9c223c..9f5b772a5c7 100644 --- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap +++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap @@ -127,21 +127,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = > <!----> - <div - class="gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5" - > - <div - class="gl-display-flex" - > - <!----> - </div> - - <div - class="gl-display-flex" - > - <!----> - </div> - </div> + <!----> <div class="gl-new-dropdown-contents" @@ -272,21 +258,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = > <!----> - <div - class="gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5" - > - <div - class="gl-display-flex" - > - <!----> - </div> - - <div - class="gl-display-flex" - > - <!----> - </div> - </div> + <!----> <div class="gl-new-dropdown-contents" diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js index f8a0059bf21..07e6ee46c41 100644 --- a/spec/frontend/jobs/components/job_app_spec.js +++ b/spec/frontend/jobs/components/job_app_spec.js @@ -2,7 +2,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Vuex from 'vuex'; -import { getJSONFixture } from 'helpers/fixtures'; +import delayedJobFixture from 'test_fixtures/jobs/delayed.json'; import { TEST_HOST } from 'helpers/test_constants'; import EmptyState from '~/jobs/components/empty_state.vue'; import EnvironmentsBlock from '~/jobs/components/environments_block.vue'; @@ -19,8 +19,6 @@ describe('Job App', () => { const localVue = createLocalVue(); localVue.use(Vuex); - const delayedJobFixture = getJSONFixture('jobs/delayed.json'); - let store; let wrapper; let mock; @@ -47,9 +45,9 @@ describe('Job App', () => { wrapper = mount(JobApp, { propsData: { ...props }, store }); }; - const setupAndMount = ({ jobData = {}, traceData = {} } = {}) => { + const setupAndMount = ({ jobData = {}, jobLogData = {} } = {}) => { mock.onGet(initSettings.endpoint).replyOnce(200, { ...job, ...jobData }); - mock.onGet(`${initSettings.pagePath}/trace.json`).reply(200, traceData); + mock.onGet(`${initSettings.pagePath}/trace.json`).reply(200, jobLogData); const asyncInit = store.dispatch('init', initSettings); @@ -77,11 +75,10 @@ describe('Job App', () => { const findEmptyState = () => wrapper.find(EmptyState); const findJobNewIssueLink = () => wrapper.find('[data-testid="job-new-issue"]'); const findJobEmptyStateTitle = () => wrapper.find('[data-testid="job-empty-state-title"]'); - const findJobTraceScrollTop = () => wrapper.find('[data-testid="job-controller-scroll-top"]'); - const findJobTraceScrollBottom = () => - wrapper.find('[data-testid="job-controller-scroll-bottom"]'); - const findJobTraceController = () => wrapper.find('[data-testid="job-raw-link-controller"]'); - const findJobTraceEraseLink = () => wrapper.find('[data-testid="job-log-erase-link"]'); + const findJobLogScrollTop = () => wrapper.find('[data-testid="job-controller-scroll-top"]'); + const findJobLogScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]'); + const findJobLogController = () => wrapper.find('[data-testid="job-raw-link-controller"]'); + const findJobLogEraseLink = () => wrapper.find('[data-testid="job-log-erase-link"]'); beforeEach(() => { mock = new MockAdapter(axios); @@ -315,7 +312,7 @@ describe('Job App', () => { }); describe('empty states block', () => { - it('renders empty state when job does not have trace and is not running', () => + it('renders empty state when job does not have log and is not running', () => setupAndMount({ jobData: { has_trace: false, @@ -342,7 +339,7 @@ describe('Job App', () => { expect(findEmptyState().exists()).toBe(true); })); - it('does not render empty state when job does not have trace but it is running', () => + it('does not render empty state when job does not have log but it is running', () => setupAndMount({ jobData: { has_trace: false, @@ -358,7 +355,7 @@ describe('Job App', () => { expect(findEmptyState().exists()).toBe(false); })); - it('does not render empty state when job has trace but it is not running', () => + it('does not render empty state when job has log but it is not running', () => setupAndMount({ jobData: { has_trace: true } }).then(() => { expect(findEmptyState().exists()).toBe(false); })); @@ -424,10 +421,10 @@ describe('Job App', () => { }); }); - describe('trace controls', () => { + describe('job log controls', () => { beforeEach(() => setupAndMount({ - traceData: { + jobLogData: { html: '<span>Update</span>', status: 'success', append: false, @@ -439,16 +436,16 @@ describe('Job App', () => { ); it('should render scroll buttons', () => { - expect(findJobTraceScrollTop().exists()).toBe(true); - expect(findJobTraceScrollBottom().exists()).toBe(true); + expect(findJobLogScrollTop().exists()).toBe(true); + expect(findJobLogScrollBottom().exists()).toBe(true); }); it('should render link to raw ouput', () => { - expect(findJobTraceController().exists()).toBe(true); + expect(findJobLogController().exists()).toBe(true); }); it('should render link to erase job', () => { - expect(findJobTraceEraseLink().exists()).toBe(true); + expect(findJobLogEraseLink().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/jobs/components/job_container_item_spec.js b/spec/frontend/jobs/components/job_container_item_spec.js index 36038b69e64..6b488821bc1 100644 --- a/spec/frontend/jobs/components/job_container_item_spec.js +++ b/spec/frontend/jobs/components/job_container_item_spec.js @@ -1,12 +1,12 @@ import { GlIcon, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import delayedJobFixture from 'test_fixtures/jobs/delayed.json'; import JobContainerItem from '~/jobs/components/job_container_item.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import job from '../mock_data'; describe('JobContainerItem', () => { let wrapper; - const delayedJobFixture = getJSONFixture('jobs/delayed.json'); const findCiIconComponent = () => wrapper.findComponent(CiIcon); const findGlIconComponent = () => wrapper.findComponent(GlIcon); diff --git a/spec/frontend/jobs/components/job_log_controllers_spec.js b/spec/frontend/jobs/components/job_log_controllers_spec.js index 97b0333cb32..0ba07522243 100644 --- a/spec/frontend/jobs/components/job_log_controllers_spec.js +++ b/spec/frontend/jobs/components/job_log_controllers_spec.js @@ -18,7 +18,7 @@ describe('Job log controllers', () => { isScrollTopDisabled: false, isScrollBottomDisabled: false, isScrollingDown: true, - isTraceSizeVisible: true, + isJobLogSizeVisible: true, }; const createWrapper = (props) => { @@ -38,7 +38,7 @@ describe('Job log controllers', () => { const findScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]'); describe('Truncate information', () => { - describe('with isTraceSizeVisible', () => { + describe('with isJobLogSizeVisible', () => { beforeEach(() => { createWrapper(); }); @@ -47,31 +47,31 @@ describe('Job log controllers', () => { expect(findTruncatedInfo().text()).toMatch('499.95 KiB'); }); - it('renders link to raw trace', () => { + it('renders link to raw job log', () => { expect(findRawLink().attributes('href')).toBe(defaultProps.rawPath); }); }); }); describe('links section', () => { - describe('with raw trace path', () => { + describe('with raw job log path', () => { beforeEach(() => { createWrapper(); }); - it('renders raw trace link', () => { + it('renders raw job log link', () => { expect(findRawLinkController().attributes('href')).toBe(defaultProps.rawPath); }); }); - describe('without raw trace path', () => { + describe('without raw job log path', () => { beforeEach(() => { createWrapper({ rawPath: null, }); }); - it('does not render raw trace link', () => { + it('does not render raw job log link', () => { expect(findRawLinkController().exists()).toBe(false); }); }); diff --git a/spec/frontend/jobs/components/log/collapsible_section_spec.js b/spec/frontend/jobs/components/log/collapsible_section_spec.js index 4e23a3ba7b8..96bdf03796b 100644 --- a/spec/frontend/jobs/components/log/collapsible_section_spec.js +++ b/spec/frontend/jobs/components/log/collapsible_section_spec.js @@ -6,7 +6,7 @@ describe('Job Log Collapsible Section', () => { let wrapper; let origGon; - const traceEndpoint = 'jobs/335'; + const jobLogEndpoint = 'jobs/335'; const findCollapsibleLine = () => wrapper.find('.collapsible-line'); const findCollapsibleLineSvg = () => wrapper.find('.collapsible-line svg'); @@ -35,7 +35,7 @@ describe('Job Log Collapsible Section', () => { beforeEach(() => { createComponent({ section: collapsibleSectionClosed, - traceEndpoint, + jobLogEndpoint, }); }); @@ -52,7 +52,7 @@ describe('Job Log Collapsible Section', () => { beforeEach(() => { createComponent({ section: collapsibleSectionOpened, - traceEndpoint, + jobLogEndpoint, }); }); @@ -72,7 +72,7 @@ describe('Job Log Collapsible Section', () => { it('emits onClickCollapsibleLine on click', () => { createComponent({ section: collapsibleSectionOpened, - traceEndpoint, + jobLogEndpoint, }); findCollapsibleLine().trigger('click'); diff --git a/spec/frontend/jobs/components/log/log_spec.js b/spec/frontend/jobs/components/log/log_spec.js index 99fb6846ce5..9a5522ab4cd 100644 --- a/spec/frontend/jobs/components/log/log_spec.js +++ b/spec/frontend/jobs/components/log/log_spec.js @@ -31,8 +31,8 @@ describe('Job Log', () => { window.gon = { features: { infinitelyCollapsibleSections: false } }; state = { - trace: logLinesParserLegacy(jobLog), - traceEndpoint: 'jobs/id', + jobLog: logLinesParserLegacy(jobLog), + jobLogEndpoint: 'jobs/id', }; store = new Vuex.Store({ @@ -59,7 +59,7 @@ describe('Job Log', () => { }); it('links to the provided path and correct line number', () => { - expect(wrapper.find('#L1').attributes('href')).toBe(`${state.traceEndpoint}#L1`); + expect(wrapper.find('#L1').attributes('href')).toBe(`${state.jobLogEndpoint}#L1`); }); }); @@ -111,8 +111,8 @@ describe('Job Log, infinitelyCollapsibleSections feature flag enabled', () => { window.gon = { features: { infinitelyCollapsibleSections: true } }; state = { - trace: logLinesParser(jobLog).parsedLines, - traceEndpoint: 'jobs/id', + jobLog: logLinesParser(jobLog).parsedLines, + jobLogEndpoint: 'jobs/id', }; store = new Vuex.Store({ @@ -139,7 +139,7 @@ describe('Job Log, infinitelyCollapsibleSections feature flag enabled', () => { }); it('links to the provided path and correct line number', () => { - expect(wrapper.find('#L1').attributes('href')).toBe(`${state.traceEndpoint}#L1`); + expect(wrapper.find('#L1').attributes('href')).toBe(`${state.jobLogEndpoint}#L1`); }); }); diff --git a/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js b/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js index 838323df755..63dcd72f967 100644 --- a/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js +++ b/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js @@ -1,9 +1,9 @@ import { shallowMount } from '@vue/test-utils'; +import delayedJobFixture from 'test_fixtures/jobs/delayed.json'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; describe('DelayedJobMixin', () => { let wrapper; - const delayedJobFixture = getJSONFixture('jobs/delayed.json'); const dummyComponent = { props: { job: { diff --git a/spec/frontend/jobs/store/actions_spec.js b/spec/frontend/jobs/store/actions_spec.js index a29bd15099f..16448d6a3ca 100644 --- a/spec/frontend/jobs/store/actions_spec.js +++ b/spec/frontend/jobs/store/actions_spec.js @@ -3,7 +3,7 @@ import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; import { setJobEndpoint, - setTraceOptions, + setJobLogOptions, clearEtagPoll, stopPolling, requestJob, @@ -12,12 +12,12 @@ import { receiveJobError, scrollTop, scrollBottom, - requestTrace, - fetchTrace, - startPollingTrace, - stopPollingTrace, - receiveTraceSuccess, - receiveTraceError, + requestJobLog, + fetchJobLog, + startPollingJobLog, + stopPollingJobLog, + receiveJobLogSuccess, + receiveJobLogError, toggleCollapsibleLine, requestJobsForStage, fetchJobsForStage, @@ -51,13 +51,13 @@ describe('Job State actions', () => { }); }); - describe('setTraceOptions', () => { - it('should commit SET_TRACE_OPTIONS mutation', (done) => { + describe('setJobLogOptions', () => { + it('should commit SET_JOB_LOG_OPTIONS mutation', (done) => { testAction( - setTraceOptions, + setJobLogOptions, { pagePath: 'job/872324/trace.json' }, mockedState, - [{ type: types.SET_TRACE_OPTIONS, payload: { pagePath: 'job/872324/trace.json' } }], + [{ type: types.SET_JOB_LOG_OPTIONS, payload: { pagePath: 'job/872324/trace.json' } }], [], done, ); @@ -191,17 +191,17 @@ describe('Job State actions', () => { }); }); - describe('requestTrace', () => { - it('should commit REQUEST_TRACE mutation', (done) => { - testAction(requestTrace, null, mockedState, [{ type: types.REQUEST_TRACE }], [], done); + describe('requestJobLog', () => { + it('should commit REQUEST_JOB_LOG mutation', (done) => { + testAction(requestJobLog, null, mockedState, [{ type: types.REQUEST_JOB_LOG }], [], done); }); }); - describe('fetchTrace', () => { + describe('fetchJobLog', () => { let mock; beforeEach(() => { - mockedState.traceEndpoint = `${TEST_HOST}/endpoint`; + mockedState.jobLogEndpoint = `${TEST_HOST}/endpoint`; mock = new MockAdapter(axios); }); @@ -212,14 +212,14 @@ describe('Job State actions', () => { }); describe('success', () => { - it('dispatches requestTrace, receiveTraceSuccess and stopPollingTrace when job is complete', (done) => { + it('dispatches requestJobLog, receiveJobLogSuccess and stopPollingJobLog when job is complete', (done) => { mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, { html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', complete: true, }); testAction( - fetchTrace, + fetchJobLog, null, mockedState, [], @@ -233,10 +233,10 @@ describe('Job State actions', () => { html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', complete: true, }, - type: 'receiveTraceSuccess', + type: 'receiveJobLogSuccess', }, { - type: 'stopPollingTrace', + type: 'stopPollingJobLog', }, ], done, @@ -244,43 +244,43 @@ describe('Job State actions', () => { }); describe('when job is incomplete', () => { - let tracePayload; + let jobLogPayload; beforeEach(() => { - tracePayload = { + jobLogPayload = { html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', complete: false, }; - mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, tracePayload); + mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, jobLogPayload); }); - it('dispatches startPollingTrace', (done) => { + it('dispatches startPollingJobLog', (done) => { testAction( - fetchTrace, + fetchJobLog, null, mockedState, [], [ { type: 'toggleScrollisInBottom', payload: true }, - { type: 'receiveTraceSuccess', payload: tracePayload }, - { type: 'startPollingTrace' }, + { type: 'receiveJobLogSuccess', payload: jobLogPayload }, + { type: 'startPollingJobLog' }, ], done, ); }); - it('does not dispatch startPollingTrace when timeout is non-empty', (done) => { - mockedState.traceTimeout = 1; + it('does not dispatch startPollingJobLog when timeout is non-empty', (done) => { + mockedState.jobLogTimeout = 1; testAction( - fetchTrace, + fetchJobLog, null, mockedState, [], [ { type: 'toggleScrollisInBottom', payload: true }, - { type: 'receiveTraceSuccess', payload: tracePayload }, + { type: 'receiveJobLogSuccess', payload: jobLogPayload }, ], done, ); @@ -293,15 +293,15 @@ describe('Job State actions', () => { mock.onGet(`${TEST_HOST}/endpoint/trace.json`).reply(500); }); - it('dispatches requestTrace and receiveTraceError ', (done) => { + it('dispatches requestJobLog and receiveJobLogError ', (done) => { testAction( - fetchTrace, + fetchJobLog, null, mockedState, [], [ { - type: 'receiveTraceError', + type: 'receiveJobLogError', }, ], done, @@ -310,7 +310,7 @@ describe('Job State actions', () => { }); }); - describe('startPollingTrace', () => { + describe('startPollingJobLog', () => { let dispatch; let commit; @@ -318,18 +318,18 @@ describe('Job State actions', () => { dispatch = jest.fn(); commit = jest.fn(); - startPollingTrace({ dispatch, commit }); + startPollingJobLog({ dispatch, commit }); }); afterEach(() => { jest.clearAllTimers(); }); - it('should save the timeout id but not call fetchTrace', () => { - expect(commit).toHaveBeenCalledWith(types.SET_TRACE_TIMEOUT, expect.any(Number)); + it('should save the timeout id but not call fetchJobLog', () => { + expect(commit).toHaveBeenCalledWith(types.SET_JOB_LOG_TIMEOUT, expect.any(Number)); expect(commit.mock.calls[0][1]).toBeGreaterThan(0); - expect(dispatch).not.toHaveBeenCalledWith('fetchTrace'); + expect(dispatch).not.toHaveBeenCalledWith('fetchJobLog'); }); describe('after timeout has passed', () => { @@ -337,14 +337,14 @@ describe('Job State actions', () => { jest.advanceTimersByTime(4000); }); - it('should clear the timeout id and fetchTrace', () => { - expect(commit).toHaveBeenCalledWith(types.SET_TRACE_TIMEOUT, 0); - expect(dispatch).toHaveBeenCalledWith('fetchTrace'); + it('should clear the timeout id and fetchJobLog', () => { + expect(commit).toHaveBeenCalledWith(types.SET_JOB_LOG_TIMEOUT, 0); + expect(dispatch).toHaveBeenCalledWith('fetchJobLog'); }); }); }); - describe('stopPollingTrace', () => { + describe('stopPollingJobLog', () => { let origTimeout; beforeEach(() => { @@ -358,40 +358,40 @@ describe('Job State actions', () => { window.clearTimeout = origTimeout; }); - it('should commit STOP_POLLING_TRACE mutation ', (done) => { - const traceTimeout = 7; + it('should commit STOP_POLLING_JOB_LOG mutation ', (done) => { + const jobLogTimeout = 7; testAction( - stopPollingTrace, + stopPollingJobLog, null, - { ...mockedState, traceTimeout }, - [{ type: types.SET_TRACE_TIMEOUT, payload: 0 }, { type: types.STOP_POLLING_TRACE }], + { ...mockedState, jobLogTimeout }, + [{ type: types.SET_JOB_LOG_TIMEOUT, payload: 0 }, { type: types.STOP_POLLING_JOB_LOG }], [], ) .then(() => { - expect(window.clearTimeout).toHaveBeenCalledWith(traceTimeout); + expect(window.clearTimeout).toHaveBeenCalledWith(jobLogTimeout); }) .then(done) .catch(done.fail); }); }); - describe('receiveTraceSuccess', () => { - it('should commit RECEIVE_TRACE_SUCCESS mutation ', (done) => { + describe('receiveJobLogSuccess', () => { + it('should commit RECEIVE_JOB_LOG_SUCCESS mutation ', (done) => { testAction( - receiveTraceSuccess, + receiveJobLogSuccess, 'hello world', mockedState, - [{ type: types.RECEIVE_TRACE_SUCCESS, payload: 'hello world' }], + [{ type: types.RECEIVE_JOB_LOG_SUCCESS, payload: 'hello world' }], [], done, ); }); }); - describe('receiveTraceError', () => { - it('should commit stop polling trace', (done) => { - testAction(receiveTraceError, null, mockedState, [], [{ type: 'stopPollingTrace' }], done); + describe('receiveJobLogError', () => { + it('should commit stop polling job log', (done) => { + testAction(receiveJobLogError, null, mockedState, [], [{ type: 'stopPollingJobLog' }], done); }); }); diff --git a/spec/frontend/jobs/store/getters_spec.js b/spec/frontend/jobs/store/getters_spec.js index 379114c3737..f26c0cf00fd 100644 --- a/spec/frontend/jobs/store/getters_spec.js +++ b/spec/frontend/jobs/store/getters_spec.js @@ -102,13 +102,13 @@ describe('Job Store Getters', () => { }); }); - describe('hasTrace', () => { + describe('hasJobLog', () => { describe('when has_trace is true', () => { it('returns true', () => { localState.job.has_trace = true; localState.job.status = {}; - expect(getters.hasTrace(localState)).toEqual(true); + expect(getters.hasJobLog(localState)).toEqual(true); }); }); @@ -117,7 +117,7 @@ describe('Job Store Getters', () => { localState.job.has_trace = false; localState.job.status = { group: 'running' }; - expect(getters.hasTrace(localState)).toEqual(true); + expect(getters.hasJobLog(localState)).toEqual(true); }); }); @@ -126,7 +126,7 @@ describe('Job Store Getters', () => { localState.job.has_trace = false; localState.job.status = { group: 'pending' }; - expect(getters.hasTrace(localState)).toEqual(false); + expect(getters.hasJobLog(localState)).toEqual(false); }); }); }); diff --git a/spec/frontend/jobs/store/mutations_spec.js b/spec/frontend/jobs/store/mutations_spec.js index 159315330e4..b73aa8abf4e 100644 --- a/spec/frontend/jobs/store/mutations_spec.js +++ b/spec/frontend/jobs/store/mutations_spec.js @@ -45,39 +45,39 @@ describe('Jobs Store Mutations', () => { }); }); - describe('RECEIVE_TRACE_SUCCESS', () => { - describe('when trace has state', () => { - it('sets traceState', () => { + describe('RECEIVE_JOB_LOG_SUCCESS', () => { + describe('when job log has state', () => { + it('sets jobLogState', () => { const stateLog = 'eyJvZmZzZXQiOjczNDQ1MSwibl9vcGVuX3RhZ3MiOjAsImZnX2NvbG9yIjpudWxsLCJiZ19jb2xvciI6bnVsbCwic3R5bGVfbWFzayI6MH0='; - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { state: stateLog, }); - expect(stateCopy.traceState).toEqual(stateLog); + expect(stateCopy.jobLogState).toEqual(stateLog); }); }); - describe('when traceSize is smaller than the total size', () => { - it('sets isTraceSizeVisible to true', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { total: 51184600, size: 1231 }); + describe('when jobLogSize is smaller than the total size', () => { + it('sets isJobLogSizeVisible to true', () => { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { total: 51184600, size: 1231 }); - expect(stateCopy.isTraceSizeVisible).toEqual(true); + expect(stateCopy.isJobLogSizeVisible).toEqual(true); }); }); - describe('when traceSize is bigger than the total size', () => { - it('sets isTraceSizeVisible to false', () => { - const copy = { ...stateCopy, traceSize: 5118460, size: 2321312 }; + describe('when jobLogSize is bigger than the total size', () => { + it('sets isJobLogSizeVisible to false', () => { + const copy = { ...stateCopy, jobLogSize: 5118460, size: 2321312 }; - mutations[types.RECEIVE_TRACE_SUCCESS](copy, { total: 511846 }); + mutations[types.RECEIVE_JOB_LOG_SUCCESS](copy, { total: 511846 }); - expect(copy.isTraceSizeVisible).toEqual(false); + expect(copy.isJobLogSizeVisible).toEqual(false); }); }); - it('sets trace, trace size and isTraceComplete', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + it('sets job log size and isJobLogComplete', () => { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { append: true, html, size: 511846, @@ -85,15 +85,15 @@ describe('Jobs Store Mutations', () => { lines: [], }); - expect(stateCopy.traceSize).toEqual(511846); - expect(stateCopy.isTraceComplete).toEqual(true); + expect(stateCopy.jobLogSize).toEqual(511846); + expect(stateCopy.isJobLogComplete).toEqual(true); }); describe('with new job log', () => { describe('log.lines', () => { describe('when append is true', () => { it('sets the parsed log ', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { append: true, size: 511846, complete: true, @@ -105,7 +105,7 @@ describe('Jobs Store Mutations', () => { ], }); - expect(stateCopy.trace).toEqual([ + expect(stateCopy.jobLog).toEqual([ { offset: 1, content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }], @@ -117,7 +117,7 @@ describe('Jobs Store Mutations', () => { describe('when it is defined', () => { it('sets the parsed log ', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { append: false, size: 511846, complete: true, @@ -126,7 +126,7 @@ describe('Jobs Store Mutations', () => { ], }); - expect(stateCopy.trace).toEqual([ + expect(stateCopy.jobLog).toEqual([ { offset: 0, content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }], @@ -138,7 +138,7 @@ describe('Jobs Store Mutations', () => { describe('when it is null', () => { it('sets the default value', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { append: true, html, size: 511846, @@ -146,30 +146,30 @@ describe('Jobs Store Mutations', () => { lines: null, }); - expect(stateCopy.trace).toEqual([]); + expect(stateCopy.jobLog).toEqual([]); }); }); }); }); }); - describe('SET_TRACE_TIMEOUT', () => { - it('sets the traceTimeout id', () => { + describe('SET_JOB_LOG_TIMEOUT', () => { + it('sets the jobLogTimeout id', () => { const id = 7; - expect(stateCopy.traceTimeout).not.toEqual(id); + expect(stateCopy.jobLogTimeout).not.toEqual(id); - mutations[types.SET_TRACE_TIMEOUT](stateCopy, id); + mutations[types.SET_JOB_LOG_TIMEOUT](stateCopy, id); - expect(stateCopy.traceTimeout).toEqual(id); + expect(stateCopy.jobLogTimeout).toEqual(id); }); }); - describe('STOP_POLLING_TRACE', () => { - it('sets isTraceComplete to true', () => { - mutations[types.STOP_POLLING_TRACE](stateCopy); + describe('STOP_POLLING_JOB_LOG', () => { + it('sets isJobLogComplete to true', () => { + mutations[types.STOP_POLLING_JOB_LOG](stateCopy); - expect(stateCopy.isTraceComplete).toEqual(true); + expect(stateCopy.isJobLogComplete).toEqual(true); }); }); @@ -296,12 +296,12 @@ describe('Job Store mutations, feature flag ON', () => { window.gon = origGon; }); - describe('RECEIVE_TRACE_SUCCESS', () => { + describe('RECEIVE_JOB_LOG_SUCCESS', () => { describe('with new job log', () => { describe('log.lines', () => { describe('when append is true', () => { it('sets the parsed log ', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { append: true, size: 511846, complete: true, @@ -313,7 +313,7 @@ describe('Job Store mutations, feature flag ON', () => { ], }); - expect(stateCopy.trace).toEqual([ + expect(stateCopy.jobLog).toEqual([ { offset: 1, content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }], @@ -325,7 +325,7 @@ describe('Job Store mutations, feature flag ON', () => { describe('when lines are defined', () => { it('sets the parsed log ', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { append: false, size: 511846, complete: true, @@ -334,7 +334,7 @@ describe('Job Store mutations, feature flag ON', () => { ], }); - expect(stateCopy.trace).toEqual([ + expect(stateCopy.jobLog).toEqual([ { offset: 0, content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }], @@ -346,7 +346,7 @@ describe('Job Store mutations, feature flag ON', () => { describe('when lines are null', () => { it('sets the default value', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { append: true, html, size: 511846, @@ -354,7 +354,7 @@ describe('Job Store mutations, feature flag ON', () => { lines: null, }); - expect(stateCopy.trace).toEqual([]); + expect(stateCopy.jobLog).toEqual([]); }); }); }); diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js index 0c5fa150002..92ac33c8792 100644 --- a/spec/frontend/jobs/store/utils_spec.js +++ b/spec/frontend/jobs/store/utils_spec.js @@ -1,7 +1,7 @@ import { logLinesParser, logLinesParserLegacy, - updateIncrementalTrace, + updateIncrementalJobLog, parseHeaderLine, parseLine, addDurationToHeader, @@ -487,11 +487,11 @@ describe('Jobs Store Utils', () => { }); }); - describe('updateIncrementalTrace', () => { + describe('updateIncrementalJobLog', () => { describe('without repeated section', () => { it('concats and parses both arrays', () => { const oldLog = logLinesParserLegacy(originalTrace); - const result = updateIncrementalTrace(regularIncremental, oldLog); + const result = updateIncrementalJobLog(regularIncremental, oldLog); expect(result).toEqual([ { @@ -519,7 +519,7 @@ describe('Jobs Store Utils', () => { describe('with regular line repeated offset', () => { it('updates the last line and formats with the incremental part', () => { const oldLog = logLinesParserLegacy(originalTrace); - const result = updateIncrementalTrace(regularIncrementalRepeated, oldLog); + const result = updateIncrementalJobLog(regularIncrementalRepeated, oldLog); expect(result).toEqual([ { @@ -538,7 +538,7 @@ describe('Jobs Store Utils', () => { describe('with header line repeated', () => { it('updates the header line and formats with the incremental part', () => { const oldLog = logLinesParserLegacy(headerTrace); - const result = updateIncrementalTrace(headerTraceIncremental, oldLog); + const result = updateIncrementalJobLog(headerTraceIncremental, oldLog); expect(result).toEqual([ { @@ -564,7 +564,7 @@ describe('Jobs Store Utils', () => { describe('with collapsible line repeated', () => { it('updates the collapsible line and formats with the incremental part', () => { const oldLog = logLinesParserLegacy(collapsibleTrace); - const result = updateIncrementalTrace(collapsibleTraceIncremental, oldLog); + const result = updateIncrementalJobLog(collapsibleTraceIncremental, oldLog); expect(result).toEqual([ { diff --git a/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js b/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js new file mode 100644 index 00000000000..852106db44e --- /dev/null +++ b/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js @@ -0,0 +1,155 @@ +import { ApolloLink, Observable } from 'apollo-link'; +import waitForPromises from 'helpers/wait_for_promises'; +import { getSuppressNetworkErrorsDuringNavigationLink } from '~/lib/apollo/suppress_network_errors_during_navigation_link'; +import { isNavigatingAway } from '~/lib/utils/is_navigating_away'; + +jest.mock('~/lib/utils/is_navigating_away'); + +describe('getSuppressNetworkErrorsDuringNavigationLink', () => { + const originalGon = window.gon; + let subscription; + + beforeEach(() => { + window.gon = originalGon; + }); + + afterEach(() => { + if (subscription) { + subscription.unsubscribe(); + } + }); + + const makeMockGraphQLErrorLink = () => + new ApolloLink(() => + Observable.of({ + errors: [ + { + message: 'foo', + }, + ], + }), + ); + + const makeMockNetworkErrorLink = () => + new ApolloLink( + () => + new Observable(() => { + throw new Error('NetworkError'); + }), + ); + + const makeMockSuccessLink = () => + new ApolloLink(() => Observable.of({ data: { foo: { id: 1 } } })); + + const createSubscription = (otherLink, observer) => { + const mockOperation = { operationName: 'foo' }; + const link = getSuppressNetworkErrorsDuringNavigationLink().concat(otherLink); + subscription = link.request(mockOperation).subscribe(observer); + }; + + describe('when disabled', () => { + it('returns null', () => { + expect(getSuppressNetworkErrorsDuringNavigationLink()).toBe(null); + }); + }); + + describe('when enabled', () => { + beforeEach(() => { + window.gon = { features: { suppressApolloErrorsDuringNavigation: true } }; + }); + + it('returns an ApolloLink', () => { + expect(getSuppressNetworkErrorsDuringNavigationLink()).toEqual(expect.any(ApolloLink)); + }); + + describe('suppression case', () => { + describe('when navigating away', () => { + beforeEach(() => { + isNavigatingAway.mockReturnValue(true); + }); + + describe('given a network error', () => { + it('does not forward the error', async () => { + const spy = jest.fn(); + + createSubscription(makeMockNetworkErrorLink(), { + next: spy, + error: spy, + complete: spy, + }); + + // It's hard to test for something _not_ happening. The best we can + // do is wait a bit to make sure nothing happens. + await waitForPromises(); + expect(spy).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('non-suppression cases', () => { + describe('when not navigating away', () => { + beforeEach(() => { + isNavigatingAway.mockReturnValue(false); + }); + + it('forwards successful requests', (done) => { + createSubscription(makeMockSuccessLink(), { + next({ data }) { + expect(data).toEqual({ foo: { id: 1 } }); + }, + error: () => done.fail('Should not happen'), + complete: () => done(), + }); + }); + + it('forwards GraphQL errors', (done) => { + createSubscription(makeMockGraphQLErrorLink(), { + next({ errors }) { + expect(errors).toEqual([{ message: 'foo' }]); + }, + error: () => done.fail('Should not happen'), + complete: () => done(), + }); + }); + + it('forwards network errors', (done) => { + createSubscription(makeMockNetworkErrorLink(), { + next: () => done.fail('Should not happen'), + error: (error) => { + expect(error.message).toBe('NetworkError'); + done(); + }, + complete: () => done.fail('Should not happen'), + }); + }); + }); + + describe('when navigating away', () => { + beforeEach(() => { + isNavigatingAway.mockReturnValue(true); + }); + + it('forwards successful requests', (done) => { + createSubscription(makeMockSuccessLink(), { + next({ data }) { + expect(data).toEqual({ foo: { id: 1 } }); + }, + error: () => done.fail('Should not happen'), + complete: () => done(), + }); + }); + + it('forwards GraphQL errors', (done) => { + createSubscription(makeMockGraphQLErrorLink(), { + next({ errors }) { + expect(errors).toEqual([{ message: 'foo' }]); + }, + error: () => done.fail('Should not happen'), + complete: () => done(), + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap b/spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap index 791ec05befd..0b156049dab 100644 --- a/spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap +++ b/spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`~/lib/logger/hello logHello console logs a friendly hello message 1`] = ` +exports[`~/lib/logger/hello logHello when on dot_com console logs a friendly hello message including the careers page 1`] = ` Array [ Array [ "%cWelcome to GitLab!%c @@ -8,7 +8,24 @@ Array [ Does this page need fixes or improvements? Open an issue or contribute a merge request to help make GitLab more lovable. At GitLab, everyone can contribute! 🤝 Contribute to GitLab: https://about.gitlab.com/community/contribute/ -🔎 Create a new GitLab issue: https://gitlab.com/gitlab-org/gitlab/-/issues/new", +🔎 Create a new GitLab issue: https://gitlab.com/gitlab-org/gitlab/-/issues/new +🚀 We like your curiosity! Help us improve GitLab by joining the team: https://about.gitlab.com/jobs/", + "padding-top: 0.5em; font-size: 2em;", + "padding-bottom: 0.5em;", + ], +] +`; + +exports[`~/lib/logger/hello logHello when on self managed console logs a friendly hello message without including the careers page 1`] = ` +Array [ + Array [ + "%cWelcome to GitLab!%c + +Does this page need fixes or improvements? Open an issue or contribute a merge request to help make GitLab more lovable. At GitLab, everyone can contribute! + +🤝 Contribute to GitLab: https://about.gitlab.com/community/contribute/ +🔎 Create a new GitLab issue: https://gitlab.com/gitlab-org/gitlab/-/issues/new +", "padding-top: 0.5em; font-size: 2em;", "padding-bottom: 0.5em;", ], diff --git a/spec/frontend/lib/logger/hello_spec.js b/spec/frontend/lib/logger/hello_spec.js index 39abe0e0dd0..39c1b55313b 100644 --- a/spec/frontend/lib/logger/hello_spec.js +++ b/spec/frontend/lib/logger/hello_spec.js @@ -9,12 +9,32 @@ describe('~/lib/logger/hello', () => { }); describe('logHello', () => { - it('console logs a friendly hello message', () => { - expect(consoleLogSpy).not.toHaveBeenCalled(); + describe('when on dot_com', () => { + beforeEach(() => { + gon.dot_com = true; + }); - logHello(); + it('console logs a friendly hello message including the careers page', () => { + expect(consoleLogSpy).not.toHaveBeenCalled(); - expect(consoleLogSpy.mock.calls).toMatchSnapshot(); + logHello(); + + expect(consoleLogSpy.mock.calls).toMatchSnapshot(); + }); + }); + + describe('when on self managed', () => { + beforeEach(() => { + gon.dot_com = false; + }); + + it('console logs a friendly hello message without including the careers page', () => { + expect(consoleLogSpy).not.toHaveBeenCalled(); + + logHello(); + + expect(consoleLogSpy.mock.calls).toMatchSnapshot(); + }); }); }); }); diff --git a/spec/frontend/lib/utils/color_utils_spec.js b/spec/frontend/lib/utils/color_utils_spec.js index c6b88b2957c..87966cf9fba 100644 --- a/spec/frontend/lib/utils/color_utils_spec.js +++ b/spec/frontend/lib/utils/color_utils_spec.js @@ -1,4 +1,5 @@ import { + isValidColorExpression, textColorForBackground, hexToRgb, validateHexColor, @@ -72,4 +73,21 @@ describe('Color utils', () => { }, ); }); + + describe('isValidColorExpression', () => { + it.each` + colorExpression | valid | desc + ${'#F00'} | ${true} | ${'valid'} + ${'rgba(0,0,0,0)'} | ${true} | ${'valid'} + ${'hsl(540,70%,50%)'} | ${true} | ${'valid'} + ${'red'} | ${true} | ${'valid'} + ${'F00'} | ${false} | ${'invalid'} + ${'F00'} | ${false} | ${'invalid'} + ${'gba(0,0,0,0)'} | ${false} | ${'invalid'} + ${'hls(540,70%,50%)'} | ${false} | ${'invalid'} + ${'hello'} | ${false} | ${'invalid'} + `('color expression $colorExpression is $desc', ({ colorExpression, valid }) => { + expect(isValidColorExpression(colorExpression)).toBe(valid); + }); + }); }); diff --git a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js index 942ba56196e..1adc70450e8 100644 --- a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js +++ b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js @@ -118,3 +118,18 @@ describe('date_format_utility.js', () => { }); }); }); + +describe('formatTimeAsSummary', () => { + it.each` + unit | value | result + ${'months'} | ${1.5} | ${'1.5M'} + ${'weeks'} | ${1.25} | ${'1.5w'} + ${'days'} | ${2} | ${'2d'} + ${'hours'} | ${10} | ${'10h'} + ${'minutes'} | ${20} | ${'20m'} + ${'seconds'} | ${10} | ${'<1m'} + ${'seconds'} | ${0} | ${'-'} + `('will format $value $unit to $result', ({ unit, value, result }) => { + expect(utils.formatTimeAsSummary({ [unit]: value })).toBe(result); + }); +}); diff --git a/spec/frontend/lib/utils/is_navigating_away_spec.js b/spec/frontend/lib/utils/is_navigating_away_spec.js new file mode 100644 index 00000000000..e1230fe96bf --- /dev/null +++ b/spec/frontend/lib/utils/is_navigating_away_spec.js @@ -0,0 +1,23 @@ +import { isNavigatingAway, setNavigatingForTestsOnly } from '~/lib/utils/is_navigating_away'; + +describe('isNavigatingAway', () => { + beforeEach(() => { + // Make sure each test starts with the same state + setNavigatingForTestsOnly(false); + }); + + it.each([false, true])('it returns the navigation flag with value %s', (flag) => { + setNavigatingForTestsOnly(flag); + expect(isNavigatingAway()).toEqual(flag); + }); + + describe('when the browser starts navigating away', () => { + it('returns true', () => { + expect(isNavigatingAway()).toEqual(false); + + window.dispatchEvent(new Event('beforeunload')); + + expect(isNavigatingAway()).toEqual(true); + }); + }); +}); diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js index 1f3659b5c76..9570d2a831c 100644 --- a/spec/frontend/lib/utils/text_utility_spec.js +++ b/spec/frontend/lib/utils/text_utility_spec.js @@ -363,4 +363,25 @@ describe('text_utility', () => { expect(textUtils.insertFinalNewline(input, '\r\n')).toBe(output); }); }); + + describe('escapeShellString', () => { + it.each` + character | input | output + ${'"'} | ${'";echo "you_shouldnt_run_this'} | ${'\'";echo "you_shouldnt_run_this\''} + ${'$'} | ${'$IFS'} | ${"'$IFS'"} + ${'\\'} | ${'evil-branch-name\\'} | ${"'evil-branch-name\\'"} + ${'!'} | ${'!event'} | ${"'!event'"} + `( + 'should not escape the $character character but wrap in single-quotes', + ({ input, output }) => { + expect(textUtils.escapeShellString(input)).toBe(output); + }, + ); + + it("should escape the ' character and wrap in single-quotes", () => { + expect(textUtils.escapeShellString("fix-'bug-behavior'")).toBe( + "'fix-'\\''bug-behavior'\\'''", + ); + }); + }); }); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 6f186ba3227..18b68d91e01 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -1004,4 +1004,39 @@ describe('URL utility', () => { expect(urlUtils.isSameOriginUrl(url)).toBe(expected); }); }); + + describe('constructWebIDEPath', () => { + let originalGl; + const projectIDEPath = '/foo/bar'; + const sourceProj = 'my_-fancy-proj/boo'; + const targetProj = 'boo/another-fancy-proj'; + const mrIid = '7'; + + beforeEach(() => { + originalGl = window.gl; + window.gl = { webIDEPath: projectIDEPath }; + }); + + afterEach(() => { + window.gl = originalGl; + }); + + it.each` + sourceProjectFullPath | targetProjectFullPath | iid | expectedPath + ${undefined} | ${undefined} | ${undefined} | ${projectIDEPath} + ${undefined} | ${undefined} | ${mrIid} | ${projectIDEPath} + ${undefined} | ${targetProj} | ${undefined} | ${projectIDEPath} + ${undefined} | ${targetProj} | ${mrIid} | ${projectIDEPath} + ${sourceProj} | ${undefined} | ${undefined} | ${projectIDEPath} + ${sourceProj} | ${targetProj} | ${undefined} | ${projectIDEPath} + ${sourceProj} | ${undefined} | ${mrIid} | ${`/-/ide/project/${sourceProj}/merge_requests/${mrIid}?target_project=`} + ${sourceProj} | ${sourceProj} | ${mrIid} | ${`/-/ide/project/${sourceProj}/merge_requests/${mrIid}?target_project=`} + ${sourceProj} | ${targetProj} | ${mrIid} | ${`/-/ide/project/${sourceProj}/merge_requests/${mrIid}?target_project=${encodeURIComponent(targetProj)}`} + `( + 'returns $expectedPath for "$sourceProjectFullPath + $targetProjectFullPath + $iid"', + ({ expectedPath, ...args } = {}) => { + expect(urlUtils.constructWebIDEPath(args)).toBe(expectedPath); + }, + ); + }); }); diff --git a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js index d8453d453e7..7eb0ea37fe6 100644 --- a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js +++ b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js @@ -45,7 +45,7 @@ describe('RemoveMemberButton', () => { title: 'Remove member', isAccessRequest: true, isInvite: true, - oncallSchedules: { name: 'user', schedules: [] }, + userDeletionObstacles: { name: 'user', obstacles: [] }, ...propsData, }, directives: { diff --git a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js index 0aa3780f030..10e451376c8 100644 --- a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js +++ b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js @@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import LeaveButton from '~/members/components/action_buttons/leave_button.vue'; import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue'; import UserActionButtons from '~/members/components/action_buttons/user_action_buttons.vue'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; import { member, orphanedMember } from '../../mock_data'; describe('UserActionButtons', () => { @@ -45,9 +46,9 @@ describe('UserActionButtons', () => { isAccessRequest: false, isInvite: false, icon: 'remove', - oncallSchedules: { + userDeletionObstacles: { name: member.user.name, - schedules: member.user.oncallSchedules, + obstacles: parseUserDeletionObstacles(member.user), }, }); }); diff --git a/spec/frontend/members/components/modals/leave_modal_spec.js b/spec/frontend/members/components/modals/leave_modal_spec.js index 1dc913e5c78..f755f08dbf2 100644 --- a/spec/frontend/members/components/modals/leave_modal_spec.js +++ b/spec/frontend/members/components/modals/leave_modal_spec.js @@ -6,7 +6,8 @@ import { nextTick } from 'vue'; import Vuex from 'vuex'; import LeaveModal from '~/members/components/modals/leave_modal.vue'; import { LEAVE_MODAL_ID, MEMBER_TYPES } from '~/members/constants'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; import { member } from '../../mock_data'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); @@ -51,7 +52,7 @@ describe('LeaveModal', () => { const findModal = () => wrapper.findComponent(GlModal); const findForm = () => findModal().findComponent(GlForm); - const findOncallSchedulesList = () => findModal().findComponent(OncallSchedulesList); + const findUserDeletionObstaclesList = () => findModal().findComponent(UserDeletionObstaclesList); const getByText = (text, options) => createWrapper(within(findModal().element).getByText(text, options)); @@ -89,25 +90,27 @@ describe('LeaveModal', () => { ); }); - describe('On-call schedules list', () => { - it("displays oncall schedules list when member's user is part of on-call schedules ", () => { - const schedulesList = findOncallSchedulesList(); - expect(schedulesList.exists()).toBe(true); - expect(schedulesList.props()).toMatchObject({ + describe('User deletion obstacles list', () => { + it("displays obstacles list when member's user is part of on-call management", () => { + const obstaclesList = findUserDeletionObstaclesList(); + expect(obstaclesList.exists()).toBe(true); + expect(obstaclesList.props()).toMatchObject({ isCurrentUser: true, - schedules: member.user.oncallSchedules, + obstacles: parseUserDeletionObstacles(member.user), }); }); - it("does NOT display oncall schedules list when member's user is NOT a part of on-call schedules ", async () => { + it("does NOT display obstacles list when member's user is NOT a part of on-call management", async () => { wrapper.destroy(); - const memberWithoutOncallSchedules = cloneDeep(member); - delete memberWithoutOncallSchedules.user.oncallSchedules; - createComponent({ member: memberWithoutOncallSchedules }); + const memberWithoutOncall = cloneDeep(member); + delete memberWithoutOncall.user.oncallSchedules; + delete memberWithoutOncall.user.escalationPolicies; + + createComponent({ member: memberWithoutOncall }); await nextTick(); - expect(findOncallSchedulesList().exists()).toBe(false); + expect(findUserDeletionObstaclesList().exists()).toBe(false); }); }); diff --git a/spec/frontend/members/components/modals/remove_member_modal_spec.js b/spec/frontend/members/components/modals/remove_member_modal_spec.js index 1dc41582c12..1d39c4b3175 100644 --- a/spec/frontend/members/components/modals/remove_member_modal_spec.js +++ b/spec/frontend/members/components/modals/remove_member_modal_spec.js @@ -4,15 +4,19 @@ import Vue from 'vue'; import Vuex from 'vuex'; import RemoveMemberModal from '~/members/components/modals/remove_member_modal.vue'; import { MEMBER_TYPES } from '~/members/constants'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; +import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; Vue.use(Vuex); describe('RemoveMemberModal', () => { const memberPath = '/gitlab-org/gitlab-test/-/project_members/90'; - const mockSchedules = { + const mockObstacles = { name: 'User1', - schedules: [{ id: 1, name: 'Schedule 1' }], + obstacles: [ + { name: 'Schedule 1', type: OBSTACLE_TYPES.oncallSchedules }, + { name: 'Policy 1', type: OBSTACLE_TYPES.escalationPolicies }, + ], }; let wrapper; @@ -44,18 +48,18 @@ describe('RemoveMemberModal', () => { const findForm = () => wrapper.find({ ref: 'form' }); const findGlModal = () => wrapper.findComponent(GlModal); - const findOnCallSchedulesList = () => wrapper.findComponent(OncallSchedulesList); + const findUserDeletionObstaclesList = () => wrapper.findComponent(UserDeletionObstaclesList); afterEach(() => { wrapper.destroy(); }); describe.each` - state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | onCallSchedules - ${'removing a group member'} | ${'GroupMember'} | ${false} | ${false} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${{}} - ${'removing a project member'} | ${'ProjectMember'} | ${false} | ${false} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockSchedules} - ${'denying an access request'} | ${'ProjectMember'} | ${true} | ${false} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${{}} - ${'revoking invite'} | ${'ProjectMember'} | ${false} | ${true} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockSchedules} + state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | userDeletionObstacles | isPartOfOncall + ${'removing a group member'} | ${'GroupMember'} | ${false} | ${false} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${{}} | ${false} + ${'removing a project member'} | ${'ProjectMember'} | ${false} | ${false} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockObstacles} | ${true} + ${'denying an access request'} | ${'ProjectMember'} | ${true} | ${false} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${{}} | ${false} + ${'revoking invite'} | ${'ProjectMember'} | ${false} | ${true} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockObstacles} | ${false} `( 'when $state', ({ @@ -66,7 +70,8 @@ describe('RemoveMemberModal', () => { message, removeSubMembershipsCheckboxExpected, unassignIssuablesCheckboxExpected, - onCallSchedules, + userDeletionObstacles, + isPartOfOncall, }) => { beforeEach(() => { createComponent({ @@ -75,12 +80,10 @@ describe('RemoveMemberModal', () => { message, memberPath, memberType, - onCallSchedules, + userDeletionObstacles, }); }); - const isPartOfOncallSchedules = Boolean(isAccessRequest && onCallSchedules.schedules?.length); - it(`has the title ${actionText}`, () => { expect(findGlModal().attributes('title')).toBe(actionText); }); @@ -109,8 +112,8 @@ describe('RemoveMemberModal', () => { ); }); - it(`shows ${isPartOfOncallSchedules ? 'all' : 'no'} related on-call schedules`, () => { - expect(findOnCallSchedulesList().exists()).toBe(isPartOfOncallSchedules); + it(`shows ${isPartOfOncall ? 'all' : 'no'} related on-call schedules or policies`, () => { + expect(findUserDeletionObstaclesList().exists()).toBe(isPartOfOncall); }); it('submits the form when the modal is submitted', () => { diff --git a/spec/frontend/members/components/table/expires_at_spec.js b/spec/frontend/members/components/table/expires_at_spec.js deleted file mode 100644 index 2b8e6ab8f2a..00000000000 --- a/spec/frontend/members/components/table/expires_at_spec.js +++ /dev/null @@ -1,86 +0,0 @@ -import { within } from '@testing-library/dom'; -import { mount, createWrapper } from '@vue/test-utils'; -import { useFakeDate } from 'helpers/fake_date'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import ExpiresAt from '~/members/components/table/expires_at.vue'; - -describe('ExpiresAt', () => { - // March 15th, 2020 - useFakeDate(2020, 2, 15); - - let wrapper; - - const createComponent = (propsData) => { - wrapper = mount(ExpiresAt, { - propsData, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - const getByText = (text, options) => - createWrapper(within(wrapper.element).getByText(text, options)); - - const getTooltipDirective = (elementWrapper) => getBinding(elementWrapper.element, 'gl-tooltip'); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when no expiration date is set', () => { - it('displays "No expiration set"', () => { - createComponent({ date: null }); - - expect(getByText('No expiration set').exists()).toBe(true); - }); - }); - - describe('when expiration date is in the past', () => { - let expiredText; - - beforeEach(() => { - createComponent({ date: '2019-03-15T00:00:00.000' }); - - expiredText = getByText('Expired'); - }); - - it('displays "Expired"', () => { - expect(expiredText.exists()).toBe(true); - expect(expiredText.classes()).toContain('gl-text-red-500'); - }); - - it('displays tooltip with formatted date', () => { - const tooltipDirective = getTooltipDirective(expiredText); - - expect(tooltipDirective).not.toBeUndefined(); - expect(expiredText.attributes('title')).toBe('Mar 15, 2019 12:00am UTC'); - }); - }); - - describe('when expiration date is in the future', () => { - it.each` - date | expected | warningColor - ${'2020-03-23T00:00:00.000'} | ${'in 8 days'} | ${false} - ${'2020-03-20T00:00:00.000'} | ${'in 5 days'} | ${true} - ${'2020-03-16T00:00:00.000'} | ${'in 1 day'} | ${true} - ${'2020-03-15T05:00:00.000'} | ${'in about 5 hours'} | ${true} - ${'2020-03-15T01:00:00.000'} | ${'in about 1 hour'} | ${true} - ${'2020-03-15T00:30:00.000'} | ${'in 30 minutes'} | ${true} - ${'2020-03-15T00:01:15.000'} | ${'in 1 minute'} | ${true} - ${'2020-03-15T00:00:15.000'} | ${'in less than a minute'} | ${true} - `('displays "$expected"', ({ date, expected, warningColor }) => { - createComponent({ date }); - - const expiredText = getByText(expected); - - expect(expiredText.exists()).toBe(true); - - if (warningColor) { - expect(expiredText.classes()).toContain('gl-text-orange-500'); - } else { - expect(expiredText.classes()).not.toContain('gl-text-orange-500'); - } - }); - }); -}); diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js index 6885da53b26..580e5edd652 100644 --- a/spec/frontend/members/components/table/members_table_spec.js +++ b/spec/frontend/members/components/table/members_table_spec.js @@ -1,22 +1,24 @@ import { GlBadge, GlPagination, GlTable } from '@gitlab/ui'; -import { - getByText as getByTextHelper, - getByTestId as getByTestIdHelper, - within, -} from '@testing-library/dom'; -import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; +import { createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import setWindowLocation from 'helpers/set_window_location_helper'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; import CreatedAt from '~/members/components/table/created_at.vue'; import ExpirationDatepicker from '~/members/components/table/expiration_datepicker.vue'; -import ExpiresAt from '~/members/components/table/expires_at.vue'; import MemberActionButtons from '~/members/components/table/member_action_buttons.vue'; import MemberAvatar from '~/members/components/table/member_avatar.vue'; import MemberSource from '~/members/components/table/member_source.vue'; import MembersTable from '~/members/components/table/members_table.vue'; import RoleDropdown from '~/members/components/table/role_dropdown.vue'; -import { MEMBER_TYPES, TAB_QUERY_PARAM_VALUES } from '~/members/constants'; +import { + MEMBER_TYPES, + MEMBER_STATE_CREATED, + MEMBER_STATE_AWAITING, + MEMBER_STATE_ACTIVE, + USER_STATE_BLOCKED_PENDING_APPROVAL, + BADGE_LABELS_PENDING_OWNER_APPROVAL, + TAB_QUERY_PARAM_VALUES, +} from '~/members/constants'; import * as initUserPopovers from '~/user_popovers'; import { member as memberMock, @@ -53,7 +55,7 @@ describe('MembersTable', () => { }; const createComponent = (state, provide = {}) => { - wrapper = mount(MembersTable, { + wrapper = mountExtended(MembersTable, { localVue, propsData: { tabQueryParamValue: TAB_QUERY_PARAM_VALUES.invite, @@ -68,7 +70,6 @@ describe('MembersTable', () => { stubs: [ 'member-avatar', 'member-source', - 'expires-at', 'created-at', 'member-action-buttons', 'role-dropdown', @@ -81,17 +82,11 @@ describe('MembersTable', () => { const url = 'https://localhost/foo-bar/-/project_members?tab=invited'; - const getByText = (text, options) => - createWrapper(getByTextHelper(wrapper.element, text, options)); - - const getByTestId = (id, options) => - createWrapper(getByTestIdHelper(wrapper.element, id, options)); - const findTable = () => wrapper.find(GlTable); const findTableCellByMemberId = (tableCellLabel, memberId) => - getByTestId(`members-table-row-${memberId}`).find( - `[data-label="${tableCellLabel}"][role="cell"]`, - ); + wrapper + .findByTestId(`members-table-row-${memberId}`) + .find(`[data-label="${tableCellLabel}"][role="cell"]`); const findPagination = () => extendedWrapper(wrapper.find(GlPagination)); @@ -103,7 +98,6 @@ describe('MembersTable', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('fields', () => { @@ -119,7 +113,6 @@ describe('MembersTable', () => { ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt} ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} - ${'expires'} | ${'Access expires'} | ${memberMock} | ${ExpiresAt} ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown} ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker} `('renders the $label field', ({ field, label, member, expectedComponent }) => { @@ -128,7 +121,7 @@ describe('MembersTable', () => { tableFields: [field], }); - expect(getByText(label, { selector: '[role="columnheader"]' }).exists()).toBe(true); + expect(wrapper.findByText(label, { selector: '[role="columnheader"]' }).exists()).toBe(true); if (expectedComponent) { expect( @@ -137,11 +130,50 @@ describe('MembersTable', () => { } }); + describe('Invited column', () => { + describe.each` + state | userState | expectedBadgeLabel + ${MEMBER_STATE_CREATED} | ${null} | ${''} + ${MEMBER_STATE_CREATED} | ${USER_STATE_BLOCKED_PENDING_APPROVAL} | ${BADGE_LABELS_PENDING_OWNER_APPROVAL} + ${MEMBER_STATE_AWAITING} | ${''} | ${''} + ${MEMBER_STATE_AWAITING} | ${USER_STATE_BLOCKED_PENDING_APPROVAL} | ${BADGE_LABELS_PENDING_OWNER_APPROVAL} + ${MEMBER_STATE_AWAITING} | ${'something_else'} | ${BADGE_LABELS_PENDING_OWNER_APPROVAL} + ${MEMBER_STATE_ACTIVE} | ${null} | ${''} + ${MEMBER_STATE_ACTIVE} | ${'something_else'} | ${''} + `('Invited Badge', ({ state, userState, expectedBadgeLabel }) => { + it(`${ + expectedBadgeLabel ? 'shows' : 'hides' + } invited badge if user status: '${userState}' and member state: '${state}'`, () => { + createComponent({ + members: [ + { + ...invite, + state, + invite: { + ...invite.invite, + userState, + }, + }, + ], + tableFields: ['invited'], + }); + + const invitedTab = wrapper.findByTestId('invited-badge'); + + if (expectedBadgeLabel) { + expect(invitedTab.text()).toBe(expectedBadgeLabel); + } else { + expect(invitedTab.exists()).toBe(false); + } + }); + }); + }); + describe('"Actions" field', () => { it('renders "Actions" field for screen readers', () => { createComponent({ members: [memberCanUpdate], tableFields: ['actions'] }); - const actionField = getByTestId('col-actions'); + const actionField = wrapper.findByTestId('col-actions'); expect(actionField.exists()).toBe(true); expect(actionField.classes('gl-sr-only')).toBe(true); @@ -154,7 +186,7 @@ describe('MembersTable', () => { it('does not render the "Actions" field', () => { createComponent({ tableFields: ['actions'] }, { currentUserId: null }); - expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null); + expect(wrapper.findByTestId('col-actions').exists()).toBe(false); }); }); @@ -177,7 +209,7 @@ describe('MembersTable', () => { it('renders the "Actions" field', () => { createComponent({ members, tableFields: ['actions'] }); - expect(getByTestId('col-actions').exists()).toBe(true); + expect(wrapper.findByTestId('col-actions').exists()).toBe(true); expect(findTableCellByMemberId('Actions', members[0].id).classes()).toStrictEqual([ 'col-actions', @@ -199,7 +231,7 @@ describe('MembersTable', () => { it('does not render the "Actions" field', () => { createComponent({ members, tableFields: ['actions'] }); - expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null); + expect(wrapper.findByTestId('col-actions').exists()).toBe(false); }); }); }); @@ -209,7 +241,7 @@ describe('MembersTable', () => { it('displays a "No members found" message', () => { createComponent(); - expect(getByText('No members found').exists()).toBe(true); + expect(wrapper.findByText('No members found').exists()).toBe(true); }); }); diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js index eb9f905fea2..f42ee295511 100644 --- a/spec/frontend/members/mock_data.js +++ b/spec/frontend/members/mock_data.js @@ -1,4 +1,4 @@ -import { MEMBER_TYPES } from '~/members/constants'; +import { MEMBER_TYPES, MEMBER_STATE_CREATED } from '~/members/constants'; export const member = { requestedAt: null, @@ -14,6 +14,7 @@ export const member = { webUrl: 'https://gitlab.com/groups/foo-bar', }, type: 'GroupMember', + state: MEMBER_STATE_CREATED, user: { id: 123, name: 'Administrator', @@ -23,6 +24,7 @@ export const member = { blocked: false, twoFactorEnabled: false, oncallSchedules: [{ name: 'schedule 1' }], + escalationPolicies: [{ name: 'policy 1' }], }, id: 238, createdAt: '2020-07-17T16:22:46.923Z', @@ -63,12 +65,13 @@ export const modalData = { memberPath: '/groups/foo-bar/-/group_members/1', memberType: 'GroupMember', message: 'Are you sure you want to remove John Smith?', - oncallSchedules: { name: 'user', schedules: [] }, + userDeletionObstacles: { name: 'user', obstacles: [] }, }; const { user, ...memberNoUser } = member; export const invite = { ...memberNoUser, + state: MEMBER_STATE_CREATED, invite: { email: 'jewel@hudsonwalter.biz', avatarUrl: 'https://www.gravatar.com/avatar/cbab7510da7eec2f60f638261b05436d?s=80&d=identicon', diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index 05538dbaeee..47b6c463377 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -37,6 +37,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` category="primary" class="flex-grow-1" clearalltext="Clear all" + clearalltextclass="gl-px-5" data-qa-selector="environments_dropdown" headertext="" hideheaderborder="true" @@ -44,7 +45,6 @@ exports[`Dashboard template matches the default snapshot 1`] = ` highlighteditemstitleclass="gl-px-5" id="monitor-environments-dropdown" menu-class="monitor-environment-dropdown-menu" - showhighlighteditemstitle="true" size="medium" text="production" toggleclass="dropdown-menu-toggle" diff --git a/spec/frontend/monitoring/fixture_data.js b/spec/frontend/monitoring/fixture_data.js index d20a111c701..6a19815883a 100644 --- a/spec/frontend/monitoring/fixture_data.js +++ b/spec/frontend/monitoring/fixture_data.js @@ -1,3 +1,4 @@ +import fixture from 'test_fixtures/metrics_dashboard/environment_metrics_dashboard.json'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { metricStates } from '~/monitoring/constants'; import { mapToDashboardViewModel } from '~/monitoring/stores/utils'; @@ -5,10 +6,7 @@ import { stateAndPropsFromDataset } from '~/monitoring/utils'; import { metricsResult } from './mock_data'; -// Use globally available `getJSONFixture` so this file can be imported by both karma and jest specs -export const metricsDashboardResponse = getJSONFixture( - 'metrics_dashboard/environment_metrics_dashboard.json', -); +export const metricsDashboardResponse = fixture; export const metricsDashboardPayload = metricsDashboardResponse.dashboard; diff --git a/spec/frontend/namespace_select_spec.js b/spec/frontend/namespace_select_spec.js deleted file mode 100644 index a38508dd601..00000000000 --- a/spec/frontend/namespace_select_spec.js +++ /dev/null @@ -1,65 +0,0 @@ -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import NamespaceSelect from '~/namespace_select'; - -jest.mock('~/deprecated_jquery_dropdown'); - -describe('NamespaceSelect', () => { - it('initializes deprecatedJQueryDropdown', () => { - const dropdown = document.createElement('div'); - - // eslint-disable-next-line no-new - new NamespaceSelect({ dropdown }); - - expect(initDeprecatedJQueryDropdown).toHaveBeenCalled(); - }); - - describe('as input', () => { - let deprecatedJQueryDropdownOptions; - - beforeEach(() => { - const dropdown = document.createElement('div'); - // eslint-disable-next-line no-new - new NamespaceSelect({ dropdown }); - [[, deprecatedJQueryDropdownOptions]] = initDeprecatedJQueryDropdown.mock.calls; - }); - - it('prevents click events', () => { - const dummyEvent = new Event('dummy'); - jest.spyOn(dummyEvent, 'preventDefault').mockImplementation(() => {}); - - // expect(foo).toContain('test'); - deprecatedJQueryDropdownOptions.clicked({ e: dummyEvent }); - - expect(dummyEvent.preventDefault).toHaveBeenCalled(); - }); - }); - - describe('as filter', () => { - let deprecatedJQueryDropdownOptions; - - beforeEach(() => { - const dropdown = document.createElement('div'); - dropdown.dataset.isFilter = 'true'; - // eslint-disable-next-line no-new - new NamespaceSelect({ dropdown }); - [[, deprecatedJQueryDropdownOptions]] = initDeprecatedJQueryDropdown.mock.calls; - }); - - it('does not prevent click events', () => { - const dummyEvent = new Event('dummy'); - jest.spyOn(dummyEvent, 'preventDefault').mockImplementation(() => {}); - - deprecatedJQueryDropdownOptions.clicked({ e: dummyEvent }); - - expect(dummyEvent.preventDefault).not.toHaveBeenCalled(); - }); - - it('sets URL of dropdown items', () => { - const dummyNamespace = { id: 'eal' }; - - const itemUrl = deprecatedJQueryDropdownOptions.url(dummyNamespace); - - expect(itemUrl).toContain(`namespace_id=${dummyNamespace.id}`); - }); - }); -}); diff --git a/spec/frontend/notebook/cells/code_spec.js b/spec/frontend/notebook/cells/code_spec.js index e14767f2594..669bdc2f89a 100644 --- a/spec/frontend/notebook/cells/code_spec.js +++ b/spec/frontend/notebook/cells/code_spec.js @@ -1,14 +1,17 @@ import Vue from 'vue'; +import fixture from 'test_fixtures/blob/notebook/basic.json'; import CodeComponent from '~/notebook/cells/code.vue'; const Component = Vue.extend(CodeComponent); describe('Code component', () => { let vm; + let json; beforeEach(() => { - json = getJSONFixture('blob/notebook/basic.json'); + // Clone fixture as it could be modified by tests + json = JSON.parse(JSON.stringify(fixture)); }); const setupComponent = (cell) => { diff --git a/spec/frontend/notebook/cells/markdown_spec.js b/spec/frontend/notebook/cells/markdown_spec.js index 707efa21528..36b1e91f15f 100644 --- a/spec/frontend/notebook/cells/markdown_spec.js +++ b/spec/frontend/notebook/cells/markdown_spec.js @@ -1,6 +1,9 @@ import { mount } from '@vue/test-utils'; import katex from 'katex'; import Vue from 'vue'; +import markdownTableJson from 'test_fixtures/blob/notebook/markdown-table.json'; +import basicJson from 'test_fixtures/blob/notebook/basic.json'; +import mathJson from 'test_fixtures/blob/notebook/math.json'; import MarkdownComponent from '~/notebook/cells/markdown.vue'; const Component = Vue.extend(MarkdownComponent); @@ -35,7 +38,7 @@ describe('Markdown component', () => { let json; beforeEach(() => { - json = getJSONFixture('blob/notebook/basic.json'); + json = basicJson; // eslint-disable-next-line prefer-destructuring cell = json.cells[1]; @@ -104,7 +107,7 @@ describe('Markdown component', () => { describe('tables', () => { beforeEach(() => { - json = getJSONFixture('blob/notebook/markdown-table.json'); + json = markdownTableJson; }); it('renders images and text', () => { @@ -135,7 +138,7 @@ describe('Markdown component', () => { describe('katex', () => { beforeEach(() => { - json = getJSONFixture('blob/notebook/math.json'); + json = mathJson; }); it('renders multi-line katex', async () => { diff --git a/spec/frontend/notebook/cells/output/index_spec.js b/spec/frontend/notebook/cells/output/index_spec.js index 2985abf0f4f..7ece73d375c 100644 --- a/spec/frontend/notebook/cells/output/index_spec.js +++ b/spec/frontend/notebook/cells/output/index_spec.js @@ -1,11 +1,11 @@ import Vue from 'vue'; +import json from 'test_fixtures/blob/notebook/basic.json'; import CodeComponent from '~/notebook/cells/output/index.vue'; const Component = Vue.extend(CodeComponent); describe('Output component', () => { let vm; - let json; const createComponent = (output) => { vm = new Component({ @@ -17,11 +17,6 @@ describe('Output component', () => { vm.$mount(); }; - beforeEach(() => { - // This is the output after rendering a jupyter notebook - json = getJSONFixture('blob/notebook/basic.json'); - }); - describe('text output', () => { beforeEach((done) => { const textType = json.cells[2]; diff --git a/spec/frontend/notebook/index_spec.js b/spec/frontend/notebook/index_spec.js index 4d0dacaf37e..cd531d628b3 100644 --- a/spec/frontend/notebook/index_spec.js +++ b/spec/frontend/notebook/index_spec.js @@ -1,18 +1,13 @@ import { mount } from '@vue/test-utils'; import Vue from 'vue'; +import json from 'test_fixtures/blob/notebook/basic.json'; +import jsonWithWorksheet from 'test_fixtures/blob/notebook/worksheets.json'; import Notebook from '~/notebook/index.vue'; const Component = Vue.extend(Notebook); describe('Notebook component', () => { let vm; - let json; - let jsonWithWorksheet; - - beforeEach(() => { - json = getJSONFixture('blob/notebook/basic.json'); - jsonWithWorksheet = getJSONFixture('blob/notebook/worksheets.json'); - }); function buildComponent(notebook) { return mount(Component, { diff --git a/spec/frontend/notes/components/comment_type_dropdown_spec.js b/spec/frontend/notes/components/comment_type_dropdown_spec.js index 5e1cb813369..8ac6144e5c8 100644 --- a/spec/frontend/notes/components/comment_type_dropdown_spec.js +++ b/spec/frontend/notes/components/comment_type_dropdown_spec.js @@ -47,8 +47,18 @@ describe('CommentTypeDropdown component', () => { it('Should emit `change` event when clicking on an alternate dropdown option', () => { mountComponent({ props: { noteType: constants.DISCUSSION } }); - findCommentDropdownOption().vm.$emit('click'); - findDiscussionDropdownOption().vm.$emit('click'); + const event = { + type: 'click', + stopPropagation: jest.fn(), + preventDefault: jest.fn(), + }; + + findCommentDropdownOption().vm.$emit('click', event); + findDiscussionDropdownOption().vm.$emit('click', event); + + // ensure the native events don't trigger anything + expect(event.stopPropagation).toHaveBeenCalledTimes(2); + expect(event.preventDefault).toHaveBeenCalledTimes(2); expect(wrapper.emitted('change')[0]).toEqual([constants.COMMENT]); expect(wrapper.emitted('change').length).toEqual(1); diff --git a/spec/frontend/notes/components/diff_with_note_spec.js b/spec/frontend/notes/components/diff_with_note_spec.js index e997fc4da50..c352265654b 100644 --- a/spec/frontend/notes/components/diff_with_note_spec.js +++ b/spec/frontend/notes/components/diff_with_note_spec.js @@ -1,10 +1,9 @@ import { shallowMount } from '@vue/test-utils'; +import discussionFixture from 'test_fixtures/merge_requests/diff_discussion.json'; +import imageDiscussionFixture from 'test_fixtures/merge_requests/image_diff_discussion.json'; import { createStore } from '~/mr_notes/stores'; import DiffWithNote from '~/notes/components/diff_with_note.vue'; -const discussionFixture = 'merge_requests/diff_discussion.json'; -const imageDiscussionFixture = 'merge_requests/image_diff_discussion.json'; - describe('diff_with_note', () => { let store; let wrapper; @@ -35,7 +34,7 @@ describe('diff_with_note', () => { describe('text diff', () => { beforeEach(() => { - const diffDiscussion = getJSONFixture(discussionFixture)[0]; + const diffDiscussion = discussionFixture[0]; wrapper = shallowMount(DiffWithNote, { propsData: { @@ -75,7 +74,7 @@ describe('diff_with_note', () => { describe('image diff', () => { beforeEach(() => { - const imageDiscussion = getJSONFixture(imageDiscussionFixture)[0]; + const imageDiscussion = imageDiscussionFixture[0]; wrapper = shallowMount(DiffWithNote, { propsData: { discussion: imageDiscussion, diffFile: {} }, store, diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index 92137d3190f..abc888cd245 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -150,6 +150,16 @@ describe('issue_note_form component', () => { expect(handleFormUpdate.length).toBe(1); }); + + it('should disable textarea when ctrl+enter is pressed', async () => { + textarea.trigger('keydown.enter', { ctrlKey: true }); + + expect(textarea.attributes('disabled')).toBeUndefined(); + + await nextTick(); + + expect(textarea.attributes('disabled')).toBe('disabled'); + }); }); }); diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js index a364a524e7b..727ef02dcbb 100644 --- a/spec/frontend/notes/components/noteable_discussion_spec.js +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import discussionWithTwoUnresolvedNotes from 'test_fixtures/merge_requests/resolved_diff_discussion.json'; import { trimText } from 'helpers/text_helper'; import mockDiffFile from 'jest/diffs/mock_data/diff_file'; import DiscussionNotes from '~/notes/components/discussion_notes.vue'; @@ -17,8 +18,6 @@ import { userDataMock, } from '../mock_data'; -const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; - describe('noteable_discussion component', () => { let store; let wrapper; @@ -119,7 +118,7 @@ describe('noteable_discussion component', () => { describe('for resolved thread', () => { beforeEach(() => { - const discussion = getJSONFixture(discussionWithTwoUnresolvedNotes)[0]; + const discussion = discussionWithTwoUnresolvedNotes[0]; wrapper.setProps({ discussion }); }); @@ -133,7 +132,7 @@ describe('noteable_discussion component', () => { describe('for unresolved thread', () => { beforeEach(() => { const discussion = { - ...getJSONFixture(discussionWithTwoUnresolvedNotes)[0], + ...discussionWithTwoUnresolvedNotes[0], expanded: true, }; discussion.resolved = false; diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js index 467a8bec21b..038aff3be04 100644 --- a/spec/frontend/notes/components/noteable_note_spec.js +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -189,6 +189,27 @@ describe('issue_note', () => { createWrapper(); }); + describe('avatar sizes in diffs', () => { + const line = { + line_code: 'abc_1_1', + type: null, + old_line: '1', + new_line: '1', + }; + + it('should render 24px avatars', async () => { + wrapper.setProps({ + note: { ...note }, + discussionRoot: true, + line, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.findComponent(UserAvatarLink).props('imgSize')).toBe(24); + }); + }); + it('should render user information', () => { const { author } = note; const avatar = wrapper.findComponent(UserAvatarLink); diff --git a/spec/frontend/notes/stores/getters_spec.js b/spec/frontend/notes/stores/getters_spec.js index 3adb5da020e..9a11fdba508 100644 --- a/spec/frontend/notes/stores/getters_spec.js +++ b/spec/frontend/notes/stores/getters_spec.js @@ -1,3 +1,4 @@ +import discussionWithTwoUnresolvedNotes from 'test_fixtures/merge_requests/resolved_diff_discussion.json'; import { DESC, ASC } from '~/notes/constants'; import * as getters from '~/notes/stores/getters'; import { @@ -17,8 +18,6 @@ import { draftDiffDiscussion, } from '../mock_data'; -const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; - // Helper function to ensure that we're using the same schema across tests. const createDiscussionNeighborParams = (discussionId, diffOrder, step) => ({ discussionId, @@ -123,7 +122,7 @@ describe('Getters Notes Store', () => { describe('resolvedDiscussionsById', () => { it('ignores unresolved system notes', () => { - const [discussion] = getJSONFixture(discussionWithTwoUnresolvedNotes); + const [discussion] = discussionWithTwoUnresolvedNotes; discussion.notes[0].resolved = true; discussion.notes[1].resolved = false; state.discussions.push(discussion); diff --git a/spec/frontend/oauth_remember_me_spec.js b/spec/frontend/oauth_remember_me_spec.js index 70bda1d9f9e..3187cbf6547 100644 --- a/spec/frontend/oauth_remember_me_spec.js +++ b/spec/frontend/oauth_remember_me_spec.js @@ -3,7 +3,7 @@ import OAuthRememberMe from '~/pages/sessions/new/oauth_remember_me'; describe('OAuthRememberMe', () => { const findFormAction = (selector) => { - return $(`#oauth-container .oauth-login${selector}`).parent('form').attr('action'); + return $(`#oauth-container .js-oauth-login${selector}`).parent('form').attr('action'); }; beforeEach(() => { 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 deleted file mode 100644 index a3423e3f4d7..00000000000 --- a/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap +++ /dev/null @@ -1,36 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ConanInstallation renders all the messages 1`] = ` -<div> - <installation-title-stub - options="[object Object]" - packagetype="conan" - /> - - <code-instruction-stub - copytext="Copy Conan Command" - instruction="foo/command" - label="Conan Command" - trackingaction="copy_conan_command" - trackinglabel="code_instruction" - /> - - <h3 - class="gl-font-lg" - > - Registry setup - </h3> - - <code-instruction-stub - copytext="Copy Conan Setup Command" - instruction="foo/setup" - label="Add Conan Remote" - trackingaction="copy_conan_setup_command" - trackinglabel="code_instruction" - /> - - <gl-sprintf-stub - message="For more information on the Conan registry, %{linkStart}see the documentation%{linkEnd}." - /> -</div> -`; diff --git a/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap deleted file mode 100644 index 39469bf4fd0..00000000000 --- a/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap +++ /dev/null @@ -1,34 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`DependencyRow renders full dependency 1`] = ` -<div - class="gl-responsive-table-row" -> - <div - class="table-section section-50" - > - <strong - class="gl-text-body" - > - Test.Dependency - </strong> - - <span - data-testid="target-framework" - > - (.NETStandard2.0) - </span> - </div> - - <div - class="table-section section-50 gl-display-flex gl-md-justify-content-end" - data-testid="version-pattern" - > - <span - class="gl-text-body" - > - 2.3.7 - </span> - </div> -</div> -`; 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 deleted file mode 100644 index 8a2793c0010..00000000000 --- a/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap +++ /dev/null @@ -1,112 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MavenInstallation groovy renders all the messages 1`] = ` -<div> - <installation-title-stub - options="[object Object],[object Object],[object Object]" - packagetype="maven" - /> - - <code-instruction-stub - class="gl-mb-5" - copytext="Copy Gradle Groovy DSL install command" - instruction="foo/gradle/groovy/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/groovy/add/source" - label="Add Gradle Groovy DSL repository command" - multiline="true" - trackingaction="copy_gradle_add_to_source_command" - trackinglabel="code_instruction" - /> -</div> -`; - -exports[`MavenInstallation kotlin renders all the messages 1`] = ` -<div> - <installation-title-stub - options="[object Object],[object Object],[object Object]" - packagetype="maven" - /> - - <code-instruction-stub - class="gl-mb-5" - copytext="Copy Gradle Kotlin DSL install command" - instruction="foo/gradle/kotlin/install" - label="Gradle Kotlin DSL install command" - trackingaction="copy_kotlin_install_command" - trackinglabel="code_instruction" - /> - - <code-instruction-stub - copytext="Copy add Gradle Kotlin DSL repository command" - instruction="foo/gradle/kotlin/add/source" - label="Add Gradle Kotlin DSL repository command" - multiline="true" - trackingaction="copy_kotlin_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],[object Object]" - packagetype="maven" - /> - - <p> - <gl-sprintf-stub - message="Copy and paste this inside your %{codeStart}pom.xml%{codeEnd} %{codeStart}dependencies%{codeEnd} block." - /> - </p> - - <code-instruction-stub - copytext="Copy Maven XML" - instruction="foo/xml" - label="" - multiline="true" - trackingaction="copy_maven_xml" - trackinglabel="code_instruction" - /> - - <code-instruction-stub - copytext="Copy Maven command" - instruction="foo/command" - label="Maven Command" - trackingaction="copy_maven_command" - trackinglabel="code_instruction" - /> - - <h3 - class="gl-font-lg" - > - Registry setup - </h3> - - <p> - <gl-sprintf-stub - message="If you haven't already done so, you will need to add the below to your %{codeStart}pom.xml%{codeEnd} file." - /> - </p> - - <code-instruction-stub - copytext="Copy Maven registry XML" - instruction="foo/setup" - label="" - multiline="true" - trackingaction="copy_maven_setup_xml" - trackinglabel="code_instruction" - /> - - <gl-sprintf-stub - message="For more information on the Maven registry, %{linkStart}see the documentation%{linkEnd}." - /> -</div> -`; 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 deleted file mode 100644 index 015c7b94dde..00000000000 --- a/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap +++ /dev/null @@ -1,36 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`NpmInstallation renders all the messages 1`] = ` -<div> - <installation-title-stub - options="[object Object],[object Object]" - packagetype="npm" - /> - - <code-instruction-stub - copytext="Copy npm command" - instruction="npm i @Test/package" - label="" - trackingaction="copy_npm_install_command" - trackinglabel="code_instruction" - /> - - <h3 - class="gl-font-lg" - > - Registry setup - </h3> - - <code-instruction-stub - copytext="Copy npm setup command" - instruction="echo @Test:registry=undefined/ >> .npmrc" - label="" - trackingaction="copy_npm_setup_command" - trackinglabel="code_instruction" - /> - - <gl-sprintf-stub - message="You may also need to setup authentication using an auth token. %{linkStart}See the documentation%{linkEnd} to find out more." - /> -</div> -`; 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 deleted file mode 100644 index 04532743952..00000000000 --- a/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap +++ /dev/null @@ -1,36 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`NugetInstallation renders all the messages 1`] = ` -<div> - <installation-title-stub - options="[object Object]" - packagetype="nuget" - /> - - <code-instruction-stub - copytext="Copy NuGet Command" - instruction="foo/command" - label="NuGet Command" - trackingaction="copy_nuget_install_command" - trackinglabel="code_instruction" - /> - - <h3 - class="gl-font-lg" - > - Registry setup - </h3> - - <code-instruction-stub - copytext="Copy NuGet Setup Command" - instruction="foo/setup" - label="Add NuGet Source" - trackingaction="copy_nuget_setup_command" - trackinglabel="code_instruction" - /> - - <gl-sprintf-stub - message="For more information on the NuGet registry, %{linkStart}see the documentation%{linkEnd}." - /> -</div> -`; diff --git a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap deleted file mode 100644 index 318cea98b92..00000000000 --- a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap +++ /dev/null @@ -1,168 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PackageTitle renders with tags 1`] = ` -<div - class="gl-display-flex gl-flex-direction-column" - data-qa-selector="package_title" -> - <div - class="gl-display-flex gl-justify-content-space-between gl-py-3" - > - <div - class="gl-flex-direction-column gl-flex-grow-1" - > - <div - class="gl-display-flex" - > - <!----> - - <div - class="gl-display-flex gl-flex-direction-column" - > - <h1 - class="gl-font-size-h1 gl-mt-3 gl-mb-2" - data-testid="title" - > - Test package - </h1> - - <div - class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" - > - <gl-icon-stub - class="gl-mr-3" - name="eye" - size="16" - /> - - <gl-sprintf-stub - message="v%{version} published %{timeAgo}" - /> - </div> - </div> - </div> - - <div - class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3" - > - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <metadata-item-stub - data-testid="package-type" - icon="package" - link="" - size="s" - text="maven" - texttooltip="" - /> - </div> - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <metadata-item-stub - data-testid="package-size" - icon="disk" - link="" - size="s" - text="300 bytes" - texttooltip="" - /> - </div> - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <package-tags-stub - hidelabel="true" - tagdisplaylimit="2" - tags="[object Object],[object Object],[object Object],[object Object]" - /> - </div> - </div> - </div> - - <!----> - </div> - - <p /> -</div> -`; - -exports[`PackageTitle renders without tags 1`] = ` -<div - class="gl-display-flex gl-flex-direction-column" - data-qa-selector="package_title" -> - <div - class="gl-display-flex gl-justify-content-space-between gl-py-3" - > - <div - class="gl-flex-direction-column gl-flex-grow-1" - > - <div - class="gl-display-flex" - > - <!----> - - <div - class="gl-display-flex gl-flex-direction-column" - > - <h1 - class="gl-font-size-h1 gl-mt-3 gl-mb-2" - data-testid="title" - > - Test package - </h1> - - <div - class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" - > - <gl-icon-stub - class="gl-mr-3" - name="eye" - size="16" - /> - - <gl-sprintf-stub - message="v%{version} published %{timeAgo}" - /> - </div> - </div> - </div> - - <div - class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3" - > - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <metadata-item-stub - data-testid="package-type" - icon="package" - link="" - size="s" - text="maven" - texttooltip="" - /> - </div> - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <metadata-item-stub - data-testid="package-size" - icon="disk" - link="" - size="s" - text="300 bytes" - texttooltip="" - /> - </div> - </div> - </div> - - <!----> - </div> - - <p /> -</div> -`; 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 deleted file mode 100644 index d5bb825d8d1..00000000000 --- a/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap +++ /dev/null @@ -1,45 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PypiInstallation renders all the messages 1`] = ` -<div> - <installation-title-stub - options="[object Object]" - packagetype="pypi" - /> - - <code-instruction-stub - copytext="Copy Pip command" - data-testid="pip-command" - instruction="pip install" - label="Pip Command" - trackingaction="copy_pip_install_command" - trackinglabel="code_instruction" - /> - - <h3 - class="gl-font-lg" - > - Registry setup - </h3> - - <p> - <gl-sprintf-stub - message="If you haven't already done so, you will need to add the below to your %{codeStart}.pypirc%{codeEnd} file." - /> - </p> - - <code-instruction-stub - copytext="Copy .pypirc content" - data-testid="pypi-setup-content" - instruction="python setup" - label="" - multiline="true" - trackingaction="copy_pypi_setup_command" - trackinglabel="code_instruction" - /> - - <gl-sprintf-stub - message="For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}." - /> -</div> -`; diff --git a/spec/frontend/packages/details/components/additional_metadata_spec.js b/spec/frontend/packages/details/components/additional_metadata_spec.js deleted file mode 100644 index b339aa84348..00000000000 --- a/spec/frontend/packages/details/components/additional_metadata_spec.js +++ /dev/null @@ -1,119 +0,0 @@ -import { GlLink, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import component from '~/packages/details/components/additional_metadata.vue'; -import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; - -import { mavenPackage, conanPackage, nugetPackage, npmPackage } from '../../mock_data'; - -describe('Package Additional Metadata', () => { - let wrapper; - const defaultProps = { - packageEntity: { ...mavenPackage }, - }; - - const mountComponent = (props) => { - wrapper = shallowMount(component, { - propsData: { ...defaultProps, ...props }, - stubs: { - DetailsRow, - GlSprintf, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - const findTitle = () => wrapper.find('[data-testid="title"]'); - const findMainArea = () => wrapper.find('[data-testid="main"]'); - const findNugetSource = () => wrapper.find('[data-testid="nuget-source"]'); - const findNugetLicense = () => wrapper.find('[data-testid="nuget-license"]'); - const findConanRecipe = () => wrapper.find('[data-testid="conan-recipe"]'); - const findMavenApp = () => wrapper.find('[data-testid="maven-app"]'); - const findMavenGroup = () => wrapper.find('[data-testid="maven-group"]'); - const findElementLink = (container) => container.find(GlLink); - - it('has the correct title', () => { - mountComponent(); - - const title = findTitle(); - - expect(title.exists()).toBe(true); - expect(title.text()).toBe('Additional Metadata'); - }); - - describe.each` - packageEntity | visible | metadata - ${mavenPackage} | ${true} | ${'maven_metadatum'} - ${conanPackage} | ${true} | ${'conan_metadatum'} - ${nugetPackage} | ${true} | ${'nuget_metadatum'} - ${npmPackage} | ${false} | ${null} - `('Component visibility', ({ packageEntity, visible, metadata }) => { - it(`Is ${visible} that the component markup is visible when the package is ${packageEntity.package_type}`, () => { - mountComponent({ packageEntity }); - - expect(findTitle().exists()).toBe(visible); - expect(findMainArea().exists()).toBe(visible); - }); - - it(`The component is hidden if ${metadata} is missing`, () => { - mountComponent({ packageEntity: { ...packageEntity, [metadata]: null } }); - - expect(findTitle().exists()).toBe(false); - expect(findMainArea().exists()).toBe(false); - }); - }); - - describe('nuget metadata', () => { - beforeEach(() => { - mountComponent({ packageEntity: nugetPackage }); - }); - - it.each` - name | finderFunction | text | link | icon - ${'source'} | ${findNugetSource} | ${'Source project located at project-foo-url'} | ${'project_url'} | ${'project'} - ${'license'} | ${findNugetLicense} | ${'License information located at license-foo-url'} | ${'license_url'} | ${'license'} - `('$name element', ({ finderFunction, text, link, icon }) => { - const element = finderFunction(); - expect(element.exists()).toBe(true); - expect(element.text()).toBe(text); - expect(element.props('icon')).toBe(icon); - expect(findElementLink(element).attributes('href')).toBe(nugetPackage.nuget_metadatum[link]); - }); - }); - - describe('conan metadata', () => { - beforeEach(() => { - mountComponent({ packageEntity: conanPackage }); - }); - - it.each` - name | finderFunction | text | icon - ${'recipe'} | ${findConanRecipe} | ${'Recipe: conan-package/1.0.0@conan+conan-package/stable'} | ${'information-o'} - `('$name element', ({ finderFunction, text, icon }) => { - const element = finderFunction(); - expect(element.exists()).toBe(true); - expect(element.text()).toBe(text); - expect(element.props('icon')).toBe(icon); - }); - }); - - describe('maven metadata', () => { - beforeEach(() => { - mountComponent(); - }); - - it.each` - name | finderFunction | text | icon - ${'app'} | ${findMavenApp} | ${'App name: test-app'} | ${'information-o'} - ${'group'} | ${findMavenGroup} | ${'App group: com.test.app'} | ${'information-o'} - `('$name element', ({ finderFunction, text, icon }) => { - const element = finderFunction(); - expect(element.exists()).toBe(true); - expect(element.text()).toBe(text); - expect(element.props('icon')).toBe(icon); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/composer_installation_spec.js b/spec/frontend/packages/details/components/composer_installation_spec.js deleted file mode 100644 index 18d11c7dd57..00000000000 --- a/spec/frontend/packages/details/components/composer_installation_spec.js +++ /dev/null @@ -1,133 +0,0 @@ -import { GlSprintf, GlLink } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -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'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('ComposerInstallation', () => { - let wrapper; - let store; - - const composerRegistryIncludeStr = 'foo/registry'; - const composerPackageIncludeStr = 'foo/package'; - - const createStore = (groupExists = true) => { - store = new Vuex.Store({ - state: { packageEntity, composerHelpPath }, - getters: { - composerRegistryInclude: () => composerRegistryIncludeStr, - composerPackageInclude: () => composerPackageIncludeStr, - groupExists: () => groupExists, - }, - }); - }; - - const findRootNode = () => wrapper.find('[data-testid="root-node"]'); - const findRegistryInclude = () => wrapper.find('[data-testid="registry-include"]'); - 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, { - localVue, - store, - stubs: { - GlSprintf, - }, - }); - } - - afterEach(() => { - 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(); - createComponent(); - }); - - it('uses code_instructions', () => { - const registryIncludeCommand = findRegistryInclude(); - expect(registryIncludeCommand.exists()).toBe(true); - expect(registryIncludeCommand.props()).toMatchObject({ - instruction: composerRegistryIncludeStr, - copyText: 'Copy registry include', - trackingAction: TrackingActions.COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND, - }); - }); - - it('has the correct title', () => { - expect(findRegistryInclude().props('label')).toBe('Add composer registry'); - }); - }); - - describe('package include command', () => { - beforeEach(() => { - createStore(); - createComponent(); - }); - - it('uses code_instructions', () => { - const registryIncludeCommand = findPackageInclude(); - expect(registryIncludeCommand.exists()).toBe(true); - expect(registryIncludeCommand.props()).toMatchObject({ - instruction: composerPackageIncludeStr, - copyText: 'Copy require package include', - trackingAction: TrackingActions.COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND, - }); - }); - - it('has the correct title', () => { - expect(findPackageInclude().props('label')).toBe('Install package version'); - }); - - it('has the correct help text', () => { - expect(findHelpText().text()).toBe( - 'For more information on Composer packages in GitLab, see the documentation.', - ); - expect(findHelpLink().attributes()).toMatchObject({ - href: composerHelpPath, - target: '_blank', - }); - }); - }); - - describe('root node', () => { - it('is normally rendered', () => { - createStore(); - createComponent(); - - expect(findRootNode().exists()).toBe(true); - }); - - it('is not rendered when the group does not exist', () => { - createStore(false); - createComponent(); - - expect(findRootNode().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/conan_installation_spec.js b/spec/frontend/packages/details/components/conan_installation_spec.js deleted file mode 100644 index 78a7d265a21..00000000000 --- a/spec/frontend/packages/details/components/conan_installation_spec.js +++ /dev/null @@ -1,72 +0,0 @@ -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'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('ConanInstallation', () => { - let wrapper; - - const conanInstallationCommandStr = 'foo/command'; - const conanSetupCommandStr = 'foo/setup'; - - const store = new Vuex.Store({ - state: { - packageEntity, - conanPath, - }, - getters: { - conanInstallationCommand: () => conanInstallationCommandStr, - conanSetupCommand: () => conanSetupCommandStr, - }, - }); - - const findCodeInstructions = () => wrapper.findAll(CodeInstructions); - const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); - - function createComponent() { - wrapper = shallowMount(ConanInstallation, { - localVue, - store, - }); - } - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - 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); - }); - }); - - describe('setup commands', () => { - it('renders the correct command', () => { - expect(findCodeInstructions().at(1).props('instruction')).toBe(conanSetupCommandStr); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/dependency_row_spec.js b/spec/frontend/packages/details/components/dependency_row_spec.js deleted file mode 100644 index 7d3ee92908d..00000000000 --- a/spec/frontend/packages/details/components/dependency_row_spec.js +++ /dev/null @@ -1,62 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import DependencyRow from '~/packages/details/components/dependency_row.vue'; -import { dependencyLinks } from '../../mock_data'; - -describe('DependencyRow', () => { - let wrapper; - - const { withoutFramework, withoutVersion, fullLink } = dependencyLinks; - - function createComponent({ dependencyLink = fullLink } = {}) { - wrapper = shallowMount(DependencyRow, { - propsData: { - dependency: dependencyLink, - }, - }); - } - - const dependencyVersion = () => wrapper.find('[data-testid="version-pattern"]'); - const dependencyFramework = () => wrapper.find('[data-testid="target-framework"]'); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('renders', () => { - it('full dependency', () => { - createComponent(); - - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - describe('version', () => { - it('does not render any version information when not supplied', () => { - createComponent({ dependencyLink: withoutVersion }); - - expect(dependencyVersion().exists()).toBe(false); - }); - - it('does render version info when it exists', () => { - createComponent(); - - expect(dependencyVersion().exists()).toBe(true); - expect(dependencyVersion().text()).toBe(fullLink.version_pattern); - }); - }); - - describe('target framework', () => { - it('does not render any framework information when not supplied', () => { - createComponent({ dependencyLink: withoutFramework }); - - expect(dependencyFramework().exists()).toBe(false); - }); - - it('does render framework info when it exists', () => { - createComponent(); - - expect(dependencyFramework().exists()).toBe(true); - expect(dependencyFramework().text()).toBe(`(${fullLink.target_framework})`); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/installation_title_spec.js b/spec/frontend/packages/details/components/installation_title_spec.js deleted file mode 100644 index 14e990d3011..00000000000 --- a/spec/frontend/packages/details/components/installation_title_spec.js +++ /dev/null @@ -1,58 +0,0 @@ -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/installations_commands_spec.js b/spec/frontend/packages/details/components/installations_commands_spec.js deleted file mode 100644 index 164f9f69741..00000000000 --- a/spec/frontend/packages/details/components/installations_commands_spec.js +++ /dev/null @@ -1,61 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import ComposerInstallation from '~/packages/details/components/composer_installation.vue'; -import ConanInstallation from '~/packages/details/components/conan_installation.vue'; -import InstallationCommands from '~/packages/details/components/installation_commands.vue'; - -import MavenInstallation from '~/packages/details/components/maven_installation.vue'; -import NpmInstallation from '~/packages/details/components/npm_installation.vue'; -import NugetInstallation from '~/packages/details/components/nuget_installation.vue'; -import PypiInstallation from '~/packages/details/components/pypi_installation.vue'; -import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/components/terraform_installation.vue'; - -import { - conanPackage, - mavenPackage, - npmPackage, - nugetPackage, - pypiPackage, - composerPackage, - terraformModule, -} from '../../mock_data'; - -describe('InstallationCommands', () => { - let wrapper; - - function createComponent(propsData) { - wrapper = shallowMount(InstallationCommands, { - propsData, - }); - } - - const npmInstallation = () => wrapper.find(NpmInstallation); - const mavenInstallation = () => wrapper.find(MavenInstallation); - const conanInstallation = () => wrapper.find(ConanInstallation); - const nugetInstallation = () => wrapper.find(NugetInstallation); - const pypiInstallation = () => wrapper.find(PypiInstallation); - const composerInstallation = () => wrapper.find(ComposerInstallation); - const terraformInstallation = () => wrapper.findComponent(TerraformInstallation); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('installation instructions', () => { - describe.each` - packageEntity | selector - ${conanPackage} | ${conanInstallation} - ${mavenPackage} | ${mavenInstallation} - ${npmPackage} | ${npmInstallation} - ${nugetPackage} | ${nugetInstallation} - ${pypiPackage} | ${pypiInstallation} - ${composerPackage} | ${composerInstallation} - ${terraformModule} | ${terraformInstallation} - `('renders', ({ packageEntity, selector }) => { - it(`${packageEntity.package_type} instructions exist`, () => { - createComponent({ packageEntity }); - - expect(selector()).toExist(); - }); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/maven_installation_spec.js b/spec/frontend/packages/details/components/maven_installation_spec.js deleted file mode 100644 index 4972fe70a3d..00000000000 --- a/spec/frontend/packages/details/components/maven_installation_spec.js +++ /dev/null @@ -1,184 +0,0 @@ -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'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('MavenInstallation', () => { - let wrapper; - - const xmlCodeBlock = 'foo/xml'; - const mavenCommandStr = 'foo/command'; - const mavenSetupXml = 'foo/setup'; - const gradleGroovyInstallCommandText = 'foo/gradle/groovy/install'; - const gradleGroovyAddSourceCommandText = 'foo/gradle/groovy/add/source'; - const gradleKotlinInstallCommandText = 'foo/gradle/kotlin/install'; - const gradleKotlinAddSourceCommandText = 'foo/gradle/kotlin/add/source'; - - const store = new Vuex.Store({ - state: { - packageEntity, - mavenPath, - }, - getters: { - mavenInstallationXml: () => xmlCodeBlock, - mavenInstallationCommand: () => mavenCommandStr, - mavenSetupXml: () => mavenSetupXml, - gradleGroovyInstalCommand: () => gradleGroovyInstallCommandText, - gradleGroovyAddSourceCommand: () => gradleGroovyAddSourceCommandText, - gradleKotlinInstalCommand: () => gradleKotlinInstallCommandText, - gradleKotlinAddSourceCommand: () => gradleKotlinAddSourceCommandText, - }, - }); - - const findCodeInstructions = () => wrapper.findAll(CodeInstructions); - const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); - - function createComponent({ data = {} } = {}) { - wrapper = shallowMount(MavenInstallation, { - localVue, - store, - data() { - return data; - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - 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: 'Maven XML' }, - { value: 'groovy', label: 'Gradle Groovy DSL' }, - { value: 'kotlin', label: 'Gradle Kotlin DSL' }, - ], - }); - }); - - 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('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, - }); - }); - }); - - 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('groovy', () => { - beforeEach(() => { - createComponent({ data: { instructionType: 'groovy' } }); - }); - - 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, - }); - }); - }); - }); - - describe('kotlin', () => { - beforeEach(() => { - createComponent({ data: { instructionType: 'kotlin' } }); - }); - - 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: gradleKotlinInstallCommandText, - multiline: false, - trackingAction: TrackingActions.COPY_KOTLIN_INSTALL_COMMAND, - }); - }); - }); - - describe('setup commands', () => { - it('renders the correct gradle command', () => { - expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: gradleKotlinAddSourceCommandText, - multiline: true, - trackingAction: TrackingActions.COPY_KOTLIN_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 deleted file mode 100644 index 1c49110bdf8..00000000000 --- a/spec/frontend/packages/details/components/npm_installation_spec.js +++ /dev/null @@ -1,123 +0,0 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { nextTick } from 'vue'; -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'; -import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('NpmInstallation', () => { - let wrapper; - - const npmInstallationCommandLabel = 'npm i @Test/package'; - const yarnInstallationCommandLabel = 'yarn add @Test/package'; - - const findCodeInstructions = () => wrapper.findAll(CodeInstructions); - const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); - - function createComponent({ data = {} } = {}) { - const store = new Vuex.Store({ - state: { - packageEntity, - nugetPath, - }, - getters: { - npmInstallationCommand, - npmSetupCommand, - }, - }); - - wrapper = shallowMount(NpmInstallation, { - localVue, - store, - data() { - return data; - }, - }); - } - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - 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' }, - { value: 'yarn', label: 'Show Yarn commands' }, - ], - }); - }); - - it('on change event updates the instructions to show', async () => { - createComponent(); - - expect(findCodeInstructions().at(0).props('instruction')).toBe(npmInstallationCommandLabel); - findInstallationTitle().vm.$emit('change', 'yarn'); - - await nextTick(); - - expect(findCodeInstructions().at(0).props('instruction')).toBe(yarnInstallationCommandLabel); - }); - }); - - describe('npm', () => { - beforeEach(() => { - createComponent(); - }); - it('renders the correct installation command', () => { - expect(findCodeInstructions().at(0).props()).toMatchObject({ - instruction: npmInstallationCommandLabel, - multiline: false, - trackingAction: TrackingActions.COPY_NPM_INSTALL_COMMAND, - }); - }); - - it('renders the correct setup command', () => { - expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: 'echo @Test:registry=undefined/ >> .npmrc', - multiline: false, - trackingAction: TrackingActions.COPY_NPM_SETUP_COMMAND, - }); - }); - }); - - describe('yarn', () => { - beforeEach(() => { - createComponent({ data: { instructionType: 'yarn' } }); - }); - - it('renders the correct setup command', () => { - expect(findCodeInstructions().at(0).props()).toMatchObject({ - instruction: yarnInstallationCommandLabel, - multiline: false, - trackingAction: TrackingActions.COPY_YARN_INSTALL_COMMAND, - }); - }); - - it('renders the correct registry command', () => { - expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: 'echo \\"@Test:registry\\" \\"undefined/\\" >> .yarnrc', - multiline: false, - trackingAction: TrackingActions.COPY_YARN_SETUP_COMMAND, - }); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/nuget_installation_spec.js b/spec/frontend/packages/details/components/nuget_installation_spec.js deleted file mode 100644 index 8839a8f1108..00000000000 --- a/spec/frontend/packages/details/components/nuget_installation_spec.js +++ /dev/null @@ -1,79 +0,0 @@ -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'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('NugetInstallation', () => { - let wrapper; - - const nugetInstallationCommandStr = 'foo/command'; - const nugetSetupCommandStr = 'foo/setup'; - - const store = new Vuex.Store({ - state: { - packageEntity, - nugetPath, - }, - getters: { - nugetInstallationCommand: () => nugetInstallationCommandStr, - nugetSetupCommand: () => nugetSetupCommandStr, - }, - }); - - const findCodeInstructions = () => wrapper.findAll(CodeInstructions); - const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); - - function createComponent() { - wrapper = shallowMount(NugetInstallation, { - localVue, - store, - }); - } - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - 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({ - instruction: nugetInstallationCommandStr, - trackingAction: TrackingActions.COPY_NUGET_INSTALL_COMMAND, - }); - }); - }); - - describe('setup commands', () => { - it('renders the correct command', () => { - expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: nugetSetupCommandStr, - trackingAction: TrackingActions.COPY_NUGET_SETUP_COMMAND, - }); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/package_title_spec.js b/spec/frontend/packages/details/components/package_title_spec.js deleted file mode 100644 index 512cec85b40..00000000000 --- a/spec/frontend/packages/details/components/package_title_spec.js +++ /dev/null @@ -1,189 +0,0 @@ -import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import PackageTitle from '~/packages/details/components/package_title.vue'; -import PackageTags from '~/packages/shared/components/package_tags.vue'; -import TitleArea from '~/vue_shared/components/registry/title_area.vue'; -import { - conanPackage, - mavenFiles, - mavenPackage, - mockTags, - npmFiles, - npmPackage, - nugetPackage, -} from '../../mock_data'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('PackageTitle', () => { - let wrapper; - let store; - - function createComponent({ - packageEntity = mavenPackage, - packageFiles = mavenFiles, - icon = null, - } = {}) { - store = new Vuex.Store({ - state: { - packageEntity, - packageFiles, - }, - getters: { - packageTypeDisplay: ({ packageEntity: { package_type: type } }) => type, - packagePipeline: ({ packageEntity: { pipeline = null } }) => pipeline, - packageIcon: () => icon, - }, - }); - - wrapper = shallowMount(PackageTitle, { - localVue, - store, - stubs: { - TitleArea, - }, - }); - return wrapper.vm.$nextTick(); - } - - const findTitleArea = () => wrapper.find(TitleArea); - const packageType = () => wrapper.find('[data-testid="package-type"]'); - const packageSize = () => wrapper.find('[data-testid="package-size"]'); - const pipelineProject = () => wrapper.find('[data-testid="pipeline-project"]'); - const packageRef = () => wrapper.find('[data-testid="package-ref"]'); - const packageTags = () => wrapper.find(PackageTags); - const packageBadges = () => wrapper.findAll('[data-testid="tag-badge"]'); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('renders', () => { - it('without tags', async () => { - await createComponent(); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('with tags', async () => { - await createComponent({ packageEntity: { ...mavenPackage, tags: mockTags } }); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('with tags on mobile', async () => { - jest.spyOn(GlBreakpointInstance, 'isDesktop').mockReturnValue(false); - await createComponent({ packageEntity: { ...mavenPackage, tags: mockTags } }); - await wrapper.vm.$nextTick(); - - expect(packageBadges()).toHaveLength(mockTags.length); - }); - }); - - describe('package title', () => { - it('is correctly bound', async () => { - await createComponent(); - - expect(findTitleArea().props('title')).toBe('Test package'); - }); - }); - - describe('package icon', () => { - const fakeSrc = 'a-fake-src'; - - it('binds an icon when provided one from vuex', async () => { - await createComponent({ icon: fakeSrc }); - - expect(findTitleArea().props('avatar')).toBe(fakeSrc); - }); - - it('do not binds an icon when not provided one', async () => { - await createComponent(); - - expect(findTitleArea().props('avatar')).toBe(null); - }); - }); - - describe.each` - packageEntity | text - ${conanPackage} | ${'conan'} - ${mavenPackage} | ${'maven'} - ${npmPackage} | ${'npm'} - ${nugetPackage} | ${'nuget'} - `(`package type`, ({ packageEntity, text }) => { - beforeEach(() => createComponent({ packageEntity })); - - it(`${packageEntity.package_type} should render from Vuex getters ${text}`, () => { - expect(packageType().props()).toEqual(expect.objectContaining({ text, icon: 'package' })); - }); - }); - - describe('calculates the package size', () => { - it('correctly calculates when there is only 1 file', async () => { - await createComponent({ packageEntity: npmPackage, packageFiles: npmFiles }); - - expect(packageSize().props()).toMatchObject({ text: '200 bytes', icon: 'disk' }); - }); - - it('correctly calulates when there are multiple files', async () => { - await createComponent(); - - expect(packageSize().props('text')).toBe('300 bytes'); - }); - }); - - describe('package tags', () => { - it('displays the package-tags component when the package has tags', async () => { - await createComponent({ - packageEntity: { - ...npmPackage, - tags: mockTags, - }, - }); - - expect(packageTags().exists()).toBe(true); - }); - - it('does not display the package-tags component when there are no tags', async () => { - await createComponent(); - - expect(packageTags().exists()).toBe(false); - }); - }); - - describe('package ref', () => { - it('does not display the ref if missing', async () => { - await createComponent(); - - expect(packageRef().exists()).toBe(false); - }); - - it('correctly shows the package ref if there is one', async () => { - await createComponent({ packageEntity: npmPackage }); - expect(packageRef().props()).toMatchObject({ - text: npmPackage.pipeline.ref, - icon: 'branch', - }); - }); - }); - - describe('pipeline project', () => { - it('does not display the project if missing', async () => { - await createComponent(); - - expect(pipelineProject().exists()).toBe(false); - }); - - it('correctly shows the pipeline project if there is one', async () => { - await createComponent({ packageEntity: npmPackage }); - - expect(pipelineProject().props()).toMatchObject({ - text: npmPackage.pipeline.project.name, - icon: 'review-list', - link: npmPackage.pipeline.project.web_url, - }); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/pypi_installation_spec.js b/spec/frontend/packages/details/components/pypi_installation_spec.js deleted file mode 100644 index 2cec84282d9..00000000000 --- a/spec/frontend/packages/details/components/pypi_installation_spec.js +++ /dev/null @@ -1,72 +0,0 @@ -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(); -localVue.use(Vuex); - -describe('PypiInstallation', () => { - let wrapper; - - const pipCommandStr = 'pip install'; - const pypiSetupStr = 'python setup'; - - const store = new Vuex.Store({ - state: { - packageEntity, - pypiHelpPath: 'foo', - }, - getters: { - pypiPipCommand: () => pipCommandStr, - pypiSetupCommand: () => pypiSetupStr, - }, - }); - - 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, - store, - }); - } - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - 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', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - describe('installation commands', () => { - it('renders the correct pip command', () => { - expect(pipCommand().props('instruction')).toBe(pipCommandStr); - }); - }); - - describe('setup commands', () => { - it('renders the correct setup block', () => { - expect(setupInstruction().props('instruction')).toBe(pypiSetupStr); - }); - }); -}); diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js deleted file mode 100644 index 8210511bf8f..00000000000 --- a/spec/frontend/packages/details/store/getters_spec.js +++ /dev/null @@ -1,295 +0,0 @@ -import { NpmManager } from '~/packages/details/constants'; -import { - conanInstallationCommand, - conanSetupCommand, - packagePipeline, - packageTypeDisplay, - packageIcon, - mavenInstallationXml, - mavenInstallationCommand, - mavenSetupXml, - npmInstallationCommand, - npmSetupCommand, - nugetInstallationCommand, - nugetSetupCommand, - pypiPipCommand, - pypiSetupCommand, - composerRegistryInclude, - composerPackageInclude, - groupExists, - gradleGroovyInstalCommand, - gradleGroovyAddSourceCommand, - gradleKotlinInstalCommand, - gradleKotlinAddSourceCommand, -} from '~/packages/details/store/getters'; -import { - conanPackage, - npmPackage, - nugetPackage, - mockPipelineInfo, - mavenPackage as packageWithoutBuildInfo, - pypiPackage, - rubygemsPackage, -} from '../../mock_data'; -import { - generateMavenCommand, - generateXmlCodeBlock, - generateMavenSetupXml, - registryUrl, - pypiSetupCommandStr, -} from '../mock_data'; - -describe('Getters PackageDetails Store', () => { - let state; - - const defaultState = { - packageEntity: packageWithoutBuildInfo, - conanPath: registryUrl, - mavenPath: registryUrl, - npmPath: registryUrl, - nugetPath: registryUrl, - pypiPath: registryUrl, - }; - - const setupState = (testState = {}) => { - state = { - ...defaultState, - ...testState, - }; - }; - - const conanInstallationCommandStr = `conan install ${conanPackage.name} --remote=gitlab`; - const conanSetupCommandStr = `conan remote add gitlab ${registryUrl}`; - - const mavenCommandStr = generateMavenCommand(packageWithoutBuildInfo.maven_metadatum); - const mavenInstallationXmlBlock = generateXmlCodeBlock(packageWithoutBuildInfo.maven_metadatum); - const mavenSetupXmlBlock = generateMavenSetupXml(); - - const npmInstallStr = `npm i ${npmPackage.name}`; - const npmSetupStr = `echo @Test:registry=${registryUrl}/ >> .npmrc`; - const yarnInstallStr = `yarn add ${npmPackage.name}`; - const yarnSetupStr = `echo \\"@Test:registry\\" \\"${registryUrl}/\\" >> .yarnrc`; - - const nugetInstallationCommandStr = `nuget install ${nugetPackage.name} -Source "GitLab"`; - const nugetSetupCommandStr = `nuget source Add -Name "GitLab" -Source "${registryUrl}" -UserName <your_username> -Password <your_token>`; - - const pypiPipCommandStr = `pip install ${pypiPackage.name} --extra-index-url ${registryUrl}`; - const composerRegistryIncludeStr = - 'composer config repositories.gitlab.com/123 \'{"type": "composer", "url": "foo"}\''; - const composerPackageIncludeStr = `composer req ${[packageWithoutBuildInfo.name]}:${ - packageWithoutBuildInfo.version - }`; - - describe('packagePipeline', () => { - it('should return the pipeline info when pipeline exists', () => { - setupState({ - packageEntity: { - ...npmPackage, - pipeline: mockPipelineInfo, - }, - }); - - expect(packagePipeline(state)).toEqual(mockPipelineInfo); - }); - - it('should return null when build_info does not exist', () => { - setupState(); - - expect(packagePipeline(state)).toBe(null); - }); - }); - - describe('packageTypeDisplay', () => { - describe.each` - packageEntity | expectedResult - ${conanPackage} | ${'Conan'} - ${packageWithoutBuildInfo} | ${'Maven'} - ${npmPackage} | ${'npm'} - ${nugetPackage} | ${'NuGet'} - ${pypiPackage} | ${'PyPI'} - ${rubygemsPackage} | ${'RubyGems'} - `(`package type`, ({ packageEntity, expectedResult }) => { - beforeEach(() => setupState({ packageEntity })); - - it(`${packageEntity.package_type} should show as ${expectedResult}`, () => { - expect(packageTypeDisplay(state)).toBe(expectedResult); - }); - }); - }); - - describe('packageIcon', () => { - describe('nuget packages', () => { - it('should return nuget package icon', () => { - setupState({ packageEntity: nugetPackage }); - - expect(packageIcon(state)).toBe(nugetPackage.nuget_metadatum.icon_url); - }); - - it('should return null when nuget package does not have an icon', () => { - setupState({ packageEntity: { ...nugetPackage, nuget_metadatum: {} } }); - - expect(packageIcon(state)).toBe(null); - }); - }); - - it('should not find icons for other package types', () => { - setupState({ packageEntity: npmPackage }); - - expect(packageIcon(state)).toBe(null); - }); - }); - - describe('conan string getters', () => { - it('gets the correct conanInstallationCommand', () => { - setupState({ packageEntity: conanPackage }); - - expect(conanInstallationCommand(state)).toBe(conanInstallationCommandStr); - }); - - it('gets the correct conanSetupCommand', () => { - setupState({ packageEntity: conanPackage }); - - expect(conanSetupCommand(state)).toBe(conanSetupCommandStr); - }); - }); - - describe('maven string getters', () => { - it('gets the correct mavenInstallationXml', () => { - setupState(); - - expect(mavenInstallationXml(state)).toBe(mavenInstallationXmlBlock); - }); - - it('gets the correct mavenInstallationCommand', () => { - setupState(); - - expect(mavenInstallationCommand(state)).toBe(mavenCommandStr); - }); - - it('gets the correct mavenSetupXml', () => { - setupState(); - - expect(mavenSetupXml(state)).toBe(mavenSetupXmlBlock); - }); - }); - - describe('npm string getters', () => { - it('gets the correct npmInstallationCommand for npm', () => { - setupState({ packageEntity: npmPackage }); - - expect(npmInstallationCommand(state)(NpmManager.NPM)).toBe(npmInstallStr); - }); - - it('gets the correct npmSetupCommand for npm', () => { - setupState({ packageEntity: npmPackage }); - - expect(npmSetupCommand(state)(NpmManager.NPM)).toBe(npmSetupStr); - }); - - it('gets the correct npmInstallationCommand for Yarn', () => { - setupState({ packageEntity: npmPackage }); - - expect(npmInstallationCommand(state)(NpmManager.YARN)).toBe(yarnInstallStr); - }); - - it('gets the correct npmSetupCommand for Yarn', () => { - setupState({ packageEntity: npmPackage }); - - expect(npmSetupCommand(state)(NpmManager.YARN)).toBe(yarnSetupStr); - }); - }); - - describe('nuget string getters', () => { - it('gets the correct nugetInstallationCommand', () => { - setupState({ packageEntity: nugetPackage }); - - expect(nugetInstallationCommand(state)).toBe(nugetInstallationCommandStr); - }); - - it('gets the correct nugetSetupCommand', () => { - setupState({ packageEntity: nugetPackage }); - - expect(nugetSetupCommand(state)).toBe(nugetSetupCommandStr); - }); - }); - - describe('pypi string getters', () => { - it('gets the correct pypiPipCommand', () => { - setupState({ packageEntity: pypiPackage }); - - expect(pypiPipCommand(state)).toBe(pypiPipCommandStr); - }); - - it('gets the correct pypiSetupCommand', () => { - setupState({ pypiSetupPath: 'foo' }); - - expect(pypiSetupCommand(state)).toBe(pypiSetupCommandStr); - }); - }); - - describe('composer string getters', () => { - it('gets the correct composerRegistryInclude command', () => { - setupState({ composerPath: 'foo', composerConfigRepositoryName: 'gitlab.com/123' }); - - expect(composerRegistryInclude(state)).toBe(composerRegistryIncludeStr); - }); - - it('gets the correct composerPackageInclude command', () => { - setupState(); - - expect(composerPackageInclude(state)).toBe(composerPackageIncludeStr); - }); - }); - - 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('gradle kotlin string getters', () => { - it('gets the correct gradleKotlinInstalCommand', () => { - setupState(); - - expect(gradleKotlinInstalCommand(state)).toMatchInlineSnapshot( - `"implementation(\\"com.test.app:test-app:1.0-SNAPSHOT\\")"`, - ); - }); - - it('gets the correct gradleKotlinAddSourceCommand', () => { - setupState(); - - expect(gradleKotlinAddSourceCommand(state)).toMatchInlineSnapshot( - `"maven(\\"foo/registry\\")"`, - ); - }); - }); - - describe('check if group', () => { - it('is set', () => { - setupState({ groupListUrl: '/groups/composer/-/packages' }); - - expect(groupExists(state)).toBe(true); - }); - - it('is not set', () => { - setupState({ groupListUrl: '' }); - - expect(groupExists(state)).toBe(false); - }); - }); -}); 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 bd15d48c4eb..5f2fc8ddfbd 100644 --- a/spec/frontend/packages/shared/components/package_list_row_spec.js +++ b/spec/frontend/packages/shared/components/package_list_row_spec.js @@ -1,5 +1,5 @@ import { GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import PackagesListRow from '~/packages/shared/components/package_list_row.vue'; @@ -19,14 +19,14 @@ describe('packages_list_row', () => { const InfrastructureIconAndName = { name: 'InfrastructureIconAndName', template: '<div></div>' }; const PackageIconAndName = { name: 'PackageIconAndName', template: '<div></div>' }; - const findPackageTags = () => wrapper.find(PackageTags); - const findPackagePath = () => wrapper.find(PackagePath); - const findDeleteButton = () => wrapper.find('[data-testid="action-delete"]'); - const findPackageIconAndName = () => wrapper.find(PackageIconAndName); + const findPackageTags = () => wrapper.findComponent(PackageTags); + const findPackagePath = () => wrapper.findComponent(PackagePath); + const findDeleteButton = () => wrapper.findByTestId('action-delete'); + const findPackageIconAndName = () => wrapper.findComponent(PackageIconAndName); const findInfrastructureIconAndName = () => wrapper.findComponent(InfrastructureIconAndName); const findListItem = () => wrapper.findComponent(ListItem); const findPackageLink = () => wrapper.findComponent(GlLink); - const findWarningIcon = () => wrapper.find('[data-testid="warning-icon"]'); + const findWarningIcon = () => wrapper.findByTestId('warning-icon'); const mountComponent = ({ isGroup = false, @@ -35,7 +35,7 @@ describe('packages_list_row', () => { disableDelete = false, provide, } = {}) => { - wrapper = shallowMount(PackagesListRow, { + wrapper = shallowMountExtended(PackagesListRow, { store, provide, stubs: { diff --git a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js new file mode 100644 index 00000000000..1f0252965b0 --- /dev/null +++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js @@ -0,0 +1,173 @@ +import { GlFormInputGroup, GlFormGroup, GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; +import { createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import DependencyProxyApp from '~/packages_and_registries/dependency_proxy/app.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql'; + +import { proxyDetailsQuery, proxyData } from './mock_data'; + +const localVue = createLocalVue(); + +describe('DependencyProxyApp', () => { + let wrapper; + let apolloProvider; + + const provideDefaults = { + groupPath: 'gitlab-org', + dependencyProxyAvailable: true, + }; + + function createComponent({ + provide = provideDefaults, + resolver = jest.fn().mockResolvedValue(proxyDetailsQuery()), + } = {}) { + localVue.use(VueApollo); + + const requestHandlers = [[getDependencyProxyDetailsQuery, resolver]]; + + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMountExtended(DependencyProxyApp, { + localVue, + apolloProvider, + provide, + stubs: { + GlFormInputGroup, + GlFormGroup, + GlSprintf, + }, + }); + } + + const findProxyNotAvailableAlert = () => wrapper.findByTestId('proxy-not-available'); + const findProxyDisabledAlert = () => wrapper.findByTestId('proxy-disabled'); + const findClipBoardButton = () => wrapper.findComponent(ClipboardButton); + const findFormGroup = () => wrapper.findComponent(GlFormGroup); + const findFormInputGroup = () => wrapper.findComponent(GlFormInputGroup); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findMainArea = () => wrapper.findByTestId('main-area'); + const findProxyCountText = () => wrapper.findByTestId('proxy-count'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when the dependency proxy is not available', () => { + const createComponentArguments = { + provide: { ...provideDefaults, dependencyProxyAvailable: false }, + }; + + it('renders an info alert', () => { + createComponent(createComponentArguments); + + expect(findProxyNotAvailableAlert().text()).toBe( + DependencyProxyApp.i18n.proxyNotAvailableText, + ); + }); + + it('does not render the main area', () => { + createComponent(createComponentArguments); + + expect(findMainArea().exists()).toBe(false); + }); + + it('does not call the graphql endpoint', async () => { + const resolver = jest.fn().mockResolvedValue(proxyDetailsQuery()); + createComponent({ ...createComponentArguments, resolver }); + + await waitForPromises(); + + expect(resolver).not.toHaveBeenCalled(); + }); + }); + + describe('when the dependency proxy is available', () => { + describe('when is loading', () => { + it('renders the skeleton loader', () => { + createComponent(); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('does not show the main section', () => { + createComponent(); + + expect(findMainArea().exists()).toBe(false); + }); + + it('does not render the info alert', () => { + createComponent(); + + expect(findProxyNotAvailableAlert().exists()).toBe(false); + }); + }); + + describe('when the app is loaded', () => { + describe('when the dependency proxy is enabled', () => { + beforeEach(() => { + createComponent(); + return waitForPromises(); + }); + + it('does not render the info alert', () => { + expect(findProxyNotAvailableAlert().exists()).toBe(false); + }); + + it('renders the main area', () => { + expect(findMainArea().exists()).toBe(true); + }); + + it('renders a form group with a label', () => { + expect(findFormGroup().attributes('label')).toBe( + DependencyProxyApp.i18n.proxyImagePrefix, + ); + }); + + it('renders a form input group', () => { + expect(findFormInputGroup().exists()).toBe(true); + expect(findFormInputGroup().props('value')).toBe(proxyData().dependencyProxyImagePrefix); + }); + + it('form input group has a clipboard button', () => { + expect(findClipBoardButton().exists()).toBe(true); + expect(findClipBoardButton().props()).toMatchObject({ + text: proxyData().dependencyProxyImagePrefix, + title: DependencyProxyApp.i18n.copyImagePrefixText, + }); + }); + + it('from group has a description with proxy count', () => { + expect(findProxyCountText().text()).toBe('Contains 2 blobs of images (1024 Bytes)'); + }); + }); + describe('when the dependency proxy is disabled', () => { + beforeEach(() => { + createComponent({ + resolver: jest + .fn() + .mockResolvedValue(proxyDetailsQuery({ extendSettings: { enabled: false } })), + }); + return waitForPromises(); + }); + + it('does not show the main area', () => { + expect(findMainArea().exists()).toBe(false); + }); + + it('does not show the loader', () => { + expect(findSkeletonLoader().exists()).toBe(false); + }); + + it('shows a proxy disabled alert', () => { + expect(findProxyDisabledAlert().text()).toBe(DependencyProxyApp.i18n.proxyDisabledText); + }); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js new file mode 100644 index 00000000000..23d42e109f9 --- /dev/null +++ b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js @@ -0,0 +1,21 @@ +export const proxyData = () => ({ + dependencyProxyBlobCount: 2, + dependencyProxyTotalSize: '1024 Bytes', + dependencyProxyImagePrefix: 'gdk.test:3000/private-group/dependency_proxy/containers', + dependencyProxySetting: { enabled: true, __typename: 'DependencyProxySetting' }, +}); + +export const proxySettings = (extend = {}) => ({ enabled: true, ...extend }); + +export const proxyDetailsQuery = ({ extendSettings = {} } = {}) => ({ + data: { + group: { + ...proxyData(), + __typename: 'Group', + dependencyProxySetting: { + ...proxySettings(extendSettings), + __typename: 'DependencyProxySetting', + }, + }, + }, +}); diff --git a/spec/frontend/packages/details/components/__snapshots__/file_sha_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap index 881d441e116..881d441e116 100644 --- a/spec/frontend/packages/details/components/__snapshots__/file_sha_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/__snapshots__/terraform_installation_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/terraform_installation_spec.js.snap index 03236737572..03236737572 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/__snapshots__/terraform_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/terraform_installation_spec.js.snap diff --git a/spec/frontend/packages/details/components/app_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js index 377e7e05f09..c7c10cef504 100644 --- a/spec/frontend/packages/details/components/app_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js @@ -5,28 +5,19 @@ import Vuex from 'vuex'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import stubChildren from 'helpers/stub_children'; -import AdditionalMetadata from '~/packages/details/components/additional_metadata.vue'; -import PackagesApp from '~/packages/details/components/app.vue'; -import DependencyRow from '~/packages/details/components/dependency_row.vue'; -import InstallationCommands from '~/packages/details/components/installation_commands.vue'; -import PackageFiles from '~/packages/details/components/package_files.vue'; -import PackageHistory from '~/packages/details/components/package_history.vue'; -import PackageTitle from '~/packages/details/components/package_title.vue'; -import * as getters from '~/packages/details/store/getters'; +import PackagesApp from '~/packages_and_registries/infrastructure_registry/details/components/app.vue'; +import PackageFiles from '~/packages_and_registries/infrastructure_registry/details/components/package_files.vue'; +import PackageHistory from '~/packages_and_registries/infrastructure_registry/details/components/package_history.vue'; +import * as getters from '~/packages_and_registries/infrastructure_registry/details/store/getters'; import PackageListRow from '~/packages/shared/components/package_list_row.vue'; import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; import { TrackingActions } from '~/packages/shared/constants'; import * as SharedUtils from '~/packages/shared/utils'; +import TerraformTitle from '~/packages_and_registries/infrastructure_registry/details/components/details_title.vue'; +import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue'; import Tracking from '~/tracking'; -import { - composerPackage, - conanPackage, - mavenPackage, - mavenFiles, - npmPackage, - nugetPackage, -} from '../../mock_data'; +import { mavenPackage, mavenFiles, npmPackage } from 'jest/packages/mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -73,7 +64,7 @@ describe('PackagesApp', () => { store, stubs: { ...stubChildren(PackagesApp), - PackageTitle: false, + TerraformTitle: false, TitleArea: false, GlButton: false, GlModal: false, @@ -84,23 +75,18 @@ describe('PackagesApp', () => { }); } - const packageTitle = () => wrapper.find(PackageTitle); - const emptyState = () => wrapper.find(GlEmptyState); + const packageTitle = () => wrapper.findComponent(TerraformTitle); + const emptyState = () => wrapper.findComponent(GlEmptyState); const deleteButton = () => wrapper.find('.js-delete-button'); const findDeleteModal = () => wrapper.find({ ref: 'deleteModal' }); const findDeleteFileModal = () => wrapper.find({ ref: 'deleteFileModal' }); const versionsTab = () => wrapper.find('.js-versions-tab > a'); - const packagesLoader = () => wrapper.find(PackagesListLoader); - const packagesVersionRows = () => wrapper.findAll(PackageListRow); + const packagesLoader = () => wrapper.findComponent(PackagesListLoader); + const packagesVersionRows = () => wrapper.findAllComponents(PackageListRow); const noVersionsMessage = () => wrapper.find('[data-testid="no-versions-message"]'); - const dependenciesTab = () => wrapper.find('.js-dependencies-tab > a'); - const dependenciesCountBadge = () => wrapper.find('[data-testid="dependencies-badge"]'); - const noDependenciesMessage = () => wrapper.find('[data-testid="no-dependencies-message"]'); - const dependencyRows = () => wrapper.findAll(DependencyRow); - const findPackageHistory = () => wrapper.find(PackageHistory); - const findAdditionalMetadata = () => wrapper.find(AdditionalMetadata); - const findInstallationCommands = () => wrapper.find(InstallationCommands); - const findPackageFiles = () => wrapper.find(PackageFiles); + const findPackageHistory = () => wrapper.findComponent(PackageHistory); + const findTerraformInstallation = () => wrapper.findComponent(TerraformInstallation); + const findPackageFiles = () => wrapper.findComponent(PackageFiles); afterEach(() => { wrapper.destroy(); @@ -129,21 +115,10 @@ describe('PackagesApp', () => { expect(findPackageHistory().props('projectName')).toEqual(wrapper.vm.projectName); }); - it('additional metadata has the right props', () => { + it('terraform installation exists', () => { createComponent(); - expect(findAdditionalMetadata().exists()).toBe(true); - expect(findAdditionalMetadata().props('packageEntity')).toEqual(wrapper.vm.packageEntity); - }); - - it('installation commands has the right props', () => { - createComponent(); - expect(findInstallationCommands().exists()).toBe(true); - expect(findInstallationCommands().props('packageEntity')).toEqual(wrapper.vm.packageEntity); - }); - it('hides the files table if package type is COMPOSER', () => { - createComponent({ packageEntity: composerPackage }); - expect(findPackageFiles().exists()).toBe(false); + expect(findTerraformInstallation().exists()).toBe(true); }); describe('deleting packages', () => { @@ -198,45 +173,6 @@ describe('PackagesApp', () => { }); }); - describe('dependency links', () => { - it('does not show the dependency links for a non nuget package', () => { - createComponent(); - - expect(dependenciesTab().exists()).toBe(false); - }); - - it('shows the dependencies tab with 0 count when a nuget package with no dependencies', () => { - createComponent({ - packageEntity: { - ...nugetPackage, - dependency_links: [], - }, - }); - - return wrapper.vm.$nextTick(() => { - const dependenciesBadge = dependenciesCountBadge(); - - expect(dependenciesTab().exists()).toBe(true); - expect(dependenciesBadge.exists()).toBe(true); - expect(dependenciesBadge.text()).toBe('0'); - expect(noDependenciesMessage().exists()).toBe(true); - }); - }); - - it('renders the correct number of dependency rows for a nuget package', () => { - createComponent({ packageEntity: nugetPackage }); - - return wrapper.vm.$nextTick(() => { - const dependenciesBadge = dependenciesCountBadge(); - - expect(dependenciesTab().exists()).toBe(true); - expect(dependenciesBadge.exists()).toBe(true); - expect(dependenciesBadge.text()).toBe(nugetPackage.dependency_links.length.toString()); - expect(dependencyRows()).toHaveLength(nugetPackage.dependency_links.length); - }); - }); - }); - describe('tracking and delete', () => { describe('delete package', () => { const originalReferrer = document.referrer; @@ -305,9 +241,9 @@ describe('PackagesApp', () => { }); it('tracking category calls packageTypeToTrackCategory', () => { - createComponent({ packageEntity: conanPackage }); + createComponent({ packageEntity: npmPackage }); expect(wrapper.vm.tracking.category).toBe(category); - expect(utilSpy).toHaveBeenCalledWith('conan'); + expect(utilSpy).toHaveBeenCalledWith('npm'); }); it(`delete button on delete modal call event with ${TrackingActions.DELETE_PACKAGE}`, () => { @@ -371,7 +307,7 @@ describe('PackagesApp', () => { }); it(`file download link call event with ${TrackingActions.PULL_PACKAGE}`, () => { - createComponent({ packageEntity: conanPackage }); + createComponent({ packageEntity: npmPackage }); findPackageFiles().vm.$emit('download-file'); expect(eventSpy).toHaveBeenCalledWith( diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details_title_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js index 87e0059344c..a012ec4ab05 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details_title_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js @@ -1,7 +1,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { terraformModule, mavenFiles, npmPackage } from 'jest/packages/mock_data'; -import component from '~/packages_and_registries/infrastructure_registry/components/details_title.vue'; +import component from '~/packages_and_registries/infrastructure_registry/details/components/details_title.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; const localVue = createLocalVue(); diff --git a/spec/frontend/packages/details/components/file_sha_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/file_sha_spec.js index 7bfcf78baab..9ce590bfb51 100644 --- a/spec/frontend/packages/details/components/file_sha_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/file_sha_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import FileSha from '~/packages/details/components/file_sha.vue'; +import FileSha from '~/packages_and_registries/infrastructure_registry/details/components/file_sha.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; diff --git a/spec/frontend/packages/details/components/package_files_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js index e8e5a24d3a3..0c5aa30223b 100644 --- a/spec/frontend/packages/details/components/package_files_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js @@ -2,11 +2,11 @@ import { GlDropdown, GlButton } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue/'; import stubChildren from 'helpers/stub_children'; -import component from '~/packages/details/components/package_files.vue'; +import component from '~/packages_and_registries/infrastructure_registry/details/components/package_files.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import { npmFiles, mavenFiles } from '../../mock_data'; +import { npmFiles, mavenFiles } from 'jest/packages/mock_data'; describe('Package Files', () => { let wrapper; diff --git a/spec/frontend/packages/details/components/package_history_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js index 244805a9c82..4987af9f5b0 100644 --- a/spec/frontend/packages/details/components/package_history_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js @@ -1,12 +1,12 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { stubComponent } from 'helpers/stub_component'; -import component from '~/packages/details/components/package_history.vue'; -import { HISTORY_PIPELINES_LIMIT } from '~/packages/details/constants'; +import component from '~/packages_and_registries/infrastructure_registry/details/components/package_history.vue'; +import { HISTORY_PIPELINES_LIMIT } from '~/packages_and_registries/shared/constants'; import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import { mavenPackage, mockPipelineInfo } from '../../mock_data'; +import { mavenPackage, mockPipelineInfo } from 'jest/packages/mock_data'; describe('Package History', () => { let wrapper; diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/terraform_installation_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js index ee1548ed5eb..c26784a4b75 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/terraform_installation_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js @@ -1,7 +1,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { terraformModule as packageEntity } from 'jest/packages/mock_data'; -import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/components/terraform_installation.vue'; +import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; const localVue = createLocalVue(); diff --git a/spec/frontend/packages/details/mock_data.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/mock_data.js index d43abcedb2e..d43abcedb2e 100644 --- a/spec/frontend/packages/details/mock_data.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/mock_data.js diff --git a/spec/frontend/packages/details/store/actions_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js index b16e50debc4..61fa69c2f7a 100644 --- a/spec/frontend/packages/details/store/actions_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js @@ -1,19 +1,19 @@ import testAction from 'helpers/vuex_action_helper'; import Api from '~/api'; import createFlash from '~/flash'; -import { FETCH_PACKAGE_VERSIONS_ERROR } from '~/packages/details/constants'; +import { FETCH_PACKAGE_VERSIONS_ERROR } from '~/packages_and_registries/infrastructure_registry/details/constants'; import { fetchPackageVersions, deletePackage, deletePackageFile, -} from '~/packages/details/store/actions'; -import * as types from '~/packages/details/store/mutation_types'; +} from '~/packages_and_registries/infrastructure_registry/details/store/actions'; +import * as types from '~/packages_and_registries/infrastructure_registry/details/store/mutation_types'; import { DELETE_PACKAGE_ERROR_MESSAGE, DELETE_PACKAGE_FILE_ERROR_MESSAGE, DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, } from '~/packages/shared/constants'; -import { npmPackage as packageEntity } from '../../mock_data'; +import { npmPackage as packageEntity } from '../../../../../packages/mock_data'; jest.mock('~/flash.js'); jest.mock('~/api.js'); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/getters_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/getters_spec.js new file mode 100644 index 00000000000..8740691a8ee --- /dev/null +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/getters_spec.js @@ -0,0 +1,40 @@ +import { packagePipeline } from '~/packages_and_registries/infrastructure_registry/details/store/getters'; +import { + npmPackage, + mockPipelineInfo, + mavenPackage as packageWithoutBuildInfo, +} from 'jest/packages/mock_data'; + +describe('Getters PackageDetails Store', () => { + let state; + + const defaultState = { + packageEntity: packageWithoutBuildInfo, + }; + + const setupState = (testState = {}) => { + state = { + ...defaultState, + ...testState, + }; + }; + + describe('packagePipeline', () => { + it('should return the pipeline info when pipeline exists', () => { + setupState({ + packageEntity: { + ...npmPackage, + pipeline: mockPipelineInfo, + }, + }); + + expect(packagePipeline(state)).toEqual(mockPipelineInfo); + }); + + it('should return null when build_info does not exist', () => { + setupState({ pipeline: undefined }); + + expect(packagePipeline(state)).toBe(null); + }); + }); +}); diff --git a/spec/frontend/packages/details/store/mutations_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/mutations_spec.js index 296ed02d786..6efefea4a14 100644 --- a/spec/frontend/packages/details/store/mutations_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/mutations_spec.js @@ -1,6 +1,6 @@ -import * as types from '~/packages/details/store/mutation_types'; -import mutations from '~/packages/details/store/mutations'; -import { npmPackage as packageEntity } from '../../mock_data'; +import * as types from '~/packages_and_registries/infrastructure_registry/details/store/mutation_types'; +import mutations from '~/packages_and_registries/infrastructure_registry/details/store/mutations'; +import { npmPackage as packageEntity } from 'jest/packages/mock_data'; describe('Mutations package details Store', () => { let mockState; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap index 6a7f14dc33f..d5649e39561 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap @@ -21,6 +21,15 @@ exports[`NpmInstallation renders all the messages 1`] = ` Registry setup </h3> + <gl-form-radio-group-stub + checked="instance" + disabledfield="disabled" + htmlfield="html" + options="[object Object],[object Object]" + textfield="text" + valuefield="value" + /> + <code-instruction-stub copytext="Copy npm setup command" instruction="echo @gitlab-org:registry=npmPath/ >> .npmrc" diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js index 279900edff2..f759fe7a81c 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js @@ -9,9 +9,8 @@ import { PACKAGE_TYPE_NUGET } from '~/packages_and_registries/package_registry/c import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; -const nugetPackage = { packageType: PACKAGE_TYPE_NUGET, metadata: nugetMetadata() }; - describe('Nuget Metadata', () => { + let nugetPackage = { packageType: PACKAGE_TYPE_NUGET, metadata: nugetMetadata() }; let wrapper; const mountComponent = () => { @@ -52,4 +51,30 @@ describe('Nuget Metadata', () => { expect(element.props('icon')).toBe(icon); expect(findElementLink(element).attributes('href')).toBe(nugetPackage.metadata[link]); }); + + describe('without source', () => { + beforeAll(() => { + nugetPackage = { + packageType: PACKAGE_TYPE_NUGET, + metadata: { iconUrl: 'iconUrl', licenseUrl: 'licenseUrl' }, + }; + }); + + it('does not show additional metadata', () => { + expect(findNugetSource().exists()).toBe(false); + }); + }); + + describe('without license', () => { + beforeAll(() => { + nugetPackage = { + packageType: PACKAGE_TYPE_NUGET, + metadata: { iconUrl: 'iconUrl', projectUrl: 'projectUrl' }, + }; + }); + + it('does not show additional metadata', () => { + expect(findNugetLicense().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js index 083c6858ad0..b89410ede13 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js @@ -1,3 +1,4 @@ +import { GlFormRadioGroup } from '@gitlab/ui'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -12,6 +13,8 @@ import { PACKAGE_TYPE_NPM, NPM_PACKAGE_MANAGER, YARN_PACKAGE_MANAGER, + PROJECT_PACKAGE_ENDPOINT_TYPE, + INSTANCE_PACKAGE_ENDPOINT_TYPE, } from '~/packages_and_registries/package_registry/constants'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; @@ -25,12 +28,14 @@ describe('NpmInstallation', () => { const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions); const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + const findEndPointTypeSector = () => wrapper.findComponent(GlFormRadioGroup); function createComponent({ data = {} } = {}) { wrapper = shallowMountExtended(NpmInstallation, { provide: { npmHelpPath: 'npmHelpPath', npmPath: 'npmPath', + npmProjectPath: 'npmProjectPath', }, propsData: { packageEntity, @@ -53,6 +58,19 @@ describe('NpmInstallation', () => { expect(wrapper.element).toMatchSnapshot(); }); + describe('endpoint type selector', () => { + it('has the endpoint type selector', () => { + expect(findEndPointTypeSector().exists()).toBe(true); + expect(findEndPointTypeSector().vm.$attrs.checked).toBe(INSTANCE_PACKAGE_ENDPOINT_TYPE); + expect(findEndPointTypeSector().props()).toMatchObject({ + options: [ + { value: INSTANCE_PACKAGE_ENDPOINT_TYPE, text: 'Instance-level' }, + { value: PROJECT_PACKAGE_ENDPOINT_TYPE, text: 'Project-level' }, + ], + }); + }); + }); + describe('install command switch', () => { it('has the installation title component', () => { expect(findInstallationTitle().exists()).toBe(true); @@ -96,6 +114,28 @@ describe('NpmInstallation', () => { trackingAction: TRACKING_ACTION_COPY_NPM_SETUP_COMMAND, }); }); + + it('renders the correct setup command for different endpoint types', async () => { + findEndPointTypeSector().vm.$emit('change', PROJECT_PACKAGE_ENDPOINT_TYPE); + + await nextTick(); + + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: `echo @gitlab-org:registry=npmProjectPath/ >> .npmrc`, + multiline: false, + trackingAction: TRACKING_ACTION_COPY_NPM_SETUP_COMMAND, + }); + + findEndPointTypeSector().vm.$emit('change', INSTANCE_PACKAGE_ENDPOINT_TYPE); + + await nextTick(); + + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: `echo @gitlab-org:registry=npmPath/ >> .npmrc`, + multiline: false, + trackingAction: TRACKING_ACTION_COPY_NPM_SETUP_COMMAND, + }); + }); }); describe('yarn', () => { @@ -118,5 +158,27 @@ describe('NpmInstallation', () => { trackingAction: TRACKING_ACTION_COPY_YARN_SETUP_COMMAND, }); }); + + it('renders the correct setup command for different endpoint types', async () => { + findEndPointTypeSector().vm.$emit('change', PROJECT_PACKAGE_ENDPOINT_TYPE); + + await nextTick(); + + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: `echo \\"@gitlab-org:registry\\" \\"npmProjectPath/\\" >> .yarnrc`, + multiline: false, + trackingAction: TRACKING_ACTION_COPY_YARN_SETUP_COMMAND, + }); + + findEndPointTypeSector().vm.$emit('change', INSTANCE_PACKAGE_ENDPOINT_TYPE); + + await nextTick(); + + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: 'echo \\"@gitlab-org:registry\\" \\"npmPath/\\" >> .yarnrc', + multiline: false, + trackingAction: TRACKING_ACTION_COPY_YARN_SETUP_COMMAND, + }); + }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js index b69008f04f0..57b8be40a7c 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js @@ -5,7 +5,7 @@ import { packageData, packagePipelines, } from 'jest/packages_and_registries/package_registry/mock_data'; -import { HISTORY_PIPELINES_LIMIT } from '~/packages/details/constants'; +import { HISTORY_PIPELINES_LIMIT } from '~/packages_and_registries/shared/constants'; import component from '~/packages_and_registries/package_registry/components/details/package_history.vue'; import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap new file mode 100644 index 00000000000..1b556be5873 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PackagesListApp renders 1`] = ` +<div> + <package-title-stub + count="2" + helpurl="packageHelpUrl" + /> + + <package-search-stub /> +</div> +`; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap new file mode 100644 index 00000000000..2f2be797251 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap @@ -0,0 +1,122 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`packages_list_row renders 1`] = ` +<div + class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid gl-border-t-1 gl-border-b-1 gl-border-t-transparent gl-border-b-gray-100" + data-qa-selector="package_row" +> + <div + class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5" + > + <!----> + + <div + class="gl-display-flex gl-xs-flex-direction-column gl-justify-content-space-between gl-align-items-stretch gl-flex-grow-1" + > + <div + class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1" + > + <div + class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0" + > + <div + class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0" + > + <gl-link-stub + class="gl-text-body gl-min-w-0" + data-qa-selector="package_link" + href="http://gdk.test:3000/gitlab-org/gitlab-test/-/packages/111" + > + <gl-truncate-stub + position="end" + text="@gitlab-org/package-15" + /> + </gl-link-stub> + + <!----> + + <!----> + </div> + + <!----> + </div> + + <div + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-grow-1" + > + <div + class="gl-display-flex" + data-testid="left-secondary-infos" + > + <span> + 1.0.0 + </span> + + <!----> + + <package-icon-and-name-stub> + + npm + + </package-icon-and-name-stub> + + <!----> + </div> + </div> + </div> + + <div + class="gl-display-flex gl-flex-direction-column gl-sm-align-items-flex-end gl-justify-content-space-between gl-text-gray-500 gl-flex-shrink-0" + > + <div + class="gl-display-flex gl-align-items-center gl-sm-text-body gl-sm-font-weight-bold gl-min-h-6" + > + <publish-method-stub /> + </div> + + <div + class="gl-display-flex gl-align-items-center gl-min-h-6" + > + <span> + Created + <timeago-tooltip-stub + cssclass="" + time="2020-08-17T14:23:32Z" + tooltipplacement="top" + /> + </span> + </div> + </div> + </div> + + <div + class="gl-w-9 gl-display-none gl-sm-display-flex gl-justify-content-end gl-pr-1" + > + <gl-button-stub + aria-label="Remove package" + buttontextclasses="" + category="secondary" + data-testid="action-delete" + icon="remove" + size="medium" + title="Remove package" + variant="danger" + /> + </div> + </div> + + <div + class="gl-display-flex" + > + <div + class="gl-w-7" + /> + + <!----> + + <div + class="gl-w-9" + /> + </div> +</div> +`; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/packages_list_app_spec.js.snap deleted file mode 100644 index dbebdeeb452..00000000000 --- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/packages_list_app_spec.js.snap +++ /dev/null @@ -1,68 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`packages_list_app renders 1`] = ` -<div> - <div - help-url="foo" - /> - - <div /> - - <div> - <section - class="row empty-state text-center" - > - <div - class="col-12" - > - <div - class="svg-250 svg-content" - > - <img - alt="" - class="gl-max-w-full" - role="img" - src="helpSvg" - /> - </div> - </div> - - <div - class="col-12" - > - <div - class="text-content gl-mx-auto gl-my-0 gl-p-5" - > - <h1 - class="h4" - > - There are no packages yet - </h1> - - <p> - Learn how to - <b-link-stub - class="gl-link" - event="click" - href="helpUrl" - routertag="a" - target="_blank" - > - publish and share your packages - </b-link-stub> - with GitLab. - </p> - - <div - class="gl-display-flex gl-flex-wrap gl-justify-content-center" - > - <!----> - - <!----> - </div> - </div> - </div> - </section> - </div> -</div> -`; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap new file mode 100644 index 00000000000..919dbe25ffe --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`publish_method renders 1`] = ` +<div + class="gl-display-flex gl-align-items-center" +> + <gl-icon-stub + class="gl-mr-2" + name="git-merge" + size="16" + /> + + <span + class="gl-mr-2" + data-testid="pipeline-ref" + > + master + </span> + + <gl-icon-stub + class="gl-mr-2" + name="commit" + size="16" + /> + + <gl-link-stub + class="gl-mr-2" + data-testid="pipeline-sha" + href="/namespace14/project14/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0" + > + b83d6e39 + </gl-link-stub> + + <clipboard-button-stub + category="tertiary" + size="small" + text="b83d6e391c22777fca1ed3012fce84f633d7fed0" + title="Copy commit SHA" + tooltipplacement="top" + /> +</div> +`; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js new file mode 100644 index 00000000000..3958cdf21bb --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js @@ -0,0 +1,154 @@ +import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; +import { createLocalVue } from '@vue/test-utils'; + +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import PackageListApp from '~/packages_and_registries/package_registry/components/list/app.vue'; +import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; +import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue'; + +import { + PROJECT_RESOURCE_TYPE, + GROUP_RESOURCE_TYPE, + LIST_QUERY_DEBOUNCE_TIME, +} from '~/packages_and_registries/package_registry/constants'; + +import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql'; + +import { packagesListQuery } from '../../mock_data'; + +jest.mock('~/lib/utils/common_utils'); +jest.mock('~/flash'); + +const localVue = createLocalVue(); + +describe('PackagesListApp', () => { + let wrapper; + let apolloProvider; + + const defaultProvide = { + packageHelpUrl: 'packageHelpUrl', + emptyListIllustration: 'emptyListIllustration', + emptyListHelpUrl: 'emptyListHelpUrl', + isGroupPage: true, + fullPath: 'gitlab-org', + }; + + const PackageList = { + name: 'package-list', + template: '<div><slot name="empty-state"></slot></div>', + }; + const GlLoadingIcon = { name: 'gl-loading-icon', template: '<div>loading</div>' }; + + const findPackageTitle = () => wrapper.findComponent(PackageTitle); + const findSearch = () => wrapper.findComponent(PackageSearch); + + const mountComponent = ({ + resolver = jest.fn().mockResolvedValue(packagesListQuery()), + provide = defaultProvide, + } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [[getPackagesQuery, resolver]]; + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMountExtended(PackageListApp, { + localVue, + apolloProvider, + provide, + stubs: { + GlEmptyState, + GlLoadingIcon, + PackageList, + GlSprintf, + GlLink, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const waitForDebouncedApollo = () => { + jest.advanceTimersByTime(LIST_QUERY_DEBOUNCE_TIME); + return waitForPromises(); + }; + + it('renders', async () => { + mountComponent(); + + await waitForDebouncedApollo(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('has a package title', async () => { + mountComponent(); + + await waitForDebouncedApollo(); + + expect(findPackageTitle().exists()).toBe(true); + expect(findPackageTitle().props('count')).toBe(2); + }); + + describe('search component', () => { + it('exists', () => { + mountComponent(); + + expect(findSearch().exists()).toBe(true); + }); + + it('on update triggers a new query with updated values', async () => { + const resolver = jest.fn().mockResolvedValue(packagesListQuery()); + mountComponent({ resolver }); + + const payload = { + sort: 'VERSION_DESC', + filters: { packageName: 'foo', packageType: 'CONAN' }, + }; + + findSearch().vm.$emit('update', payload); + + await waitForDebouncedApollo(); + jest.advanceTimersByTime(LIST_QUERY_DEBOUNCE_TIME); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ + groupSort: payload.sort, + ...payload.filters, + }), + ); + }); + }); + + describe.each` + type | sortType + ${PROJECT_RESOURCE_TYPE} | ${'sort'} + ${GROUP_RESOURCE_TYPE} | ${'groupSort'} + `('$type query', ({ type, sortType }) => { + let provide; + let resolver; + + const isGroupPage = type === GROUP_RESOURCE_TYPE; + + beforeEach(() => { + provide = { ...defaultProvide, isGroupPage }; + resolver = jest.fn().mockResolvedValue(packagesListQuery(type)); + mountComponent({ provide, resolver }); + return waitForDebouncedApollo(); + }); + + it('succeeds', () => { + expect(findPackageTitle().props('count')).toBe(2); + }); + + it('calls the resolver with the right parameters', () => { + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ isGroupPage, [sortType]: '' }), + ); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js new file mode 100644 index 00000000000..a276db104d7 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js @@ -0,0 +1,156 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; +import PackagePath from '~/packages/shared/components/package_path.vue'; +import PackageTags from '~/packages/shared/components/package_tags.vue'; +import PackageIconAndName from '~/packages/shared/components/package_icon_and_name.vue'; +import { PACKAGE_ERROR_STATUS } from '~/packages_and_registries/package_registry/constants'; + +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import { packageData, packagePipelines, packageProject, packageTags } from '../../mock_data'; + +describe('packages_list_row', () => { + let wrapper; + + const defaultProvide = { + isGroupPage: false, + }; + + const packageWithoutTags = { ...packageData(), project: packageProject() }; + const packageWithTags = { ...packageWithoutTags, tags: { nodes: packageTags() } }; + + const findPackageTags = () => wrapper.find(PackageTags); + const findPackagePath = () => wrapper.find(PackagePath); + const findDeleteButton = () => wrapper.findByTestId('action-delete'); + const findPackageIconAndName = () => wrapper.find(PackageIconAndName); + const findListItem = () => wrapper.findComponent(ListItem); + const findPackageLink = () => wrapper.findComponent(GlLink); + const findWarningIcon = () => wrapper.findByTestId('warning-icon'); + const findLeftSecondaryInfos = () => wrapper.findByTestId('left-secondary-infos'); + + const mountComponent = ({ + packageEntity = packageWithoutTags, + provide = defaultProvide, + } = {}) => { + wrapper = shallowMountExtended(PackagesListRow, { + provide, + stubs: { + ListItem, + GlSprintf, + }, + propsData: { + packageEntity, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + mountComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('tags', () => { + it('renders package tags when a package has tags', () => { + mountComponent({ packageEntity: packageWithTags }); + + expect(findPackageTags().exists()).toBe(true); + }); + + it('does not render when there are no tags', () => { + mountComponent(); + + expect(findPackageTags().exists()).toBe(false); + }); + }); + + describe('when it is group', () => { + it('has a package path component', () => { + mountComponent({ provide: { isGroupPage: true } }); + + expect(findPackagePath().exists()).toBe(true); + expect(findPackagePath().props()).toMatchObject({ path: 'gitlab-org/gitlab-test' }); + }); + }); + + 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(); + expect(wrapper.emitted('packageToDelete')).toBeTruthy(); + expect(wrapper.emitted('packageToDelete')[0]).toEqual([packageWithoutTags]); + }); + }); + + describe(`when the package is in ${PACKAGE_ERROR_STATUS} status`, () => { + beforeEach(() => { + mountComponent({ packageEntity: { ...packageWithoutTags, status: PACKAGE_ERROR_STATUS } }); + }); + + it('list item has a disabled prop', () => { + expect(findListItem().props('disabled')).toBe(true); + }); + + it('details link is disabled', () => { + expect(findPackageLink().attributes('disabled')).toBe('true'); + }); + + it('has a warning icon', () => { + const icon = findWarningIcon(); + const tooltip = getBinding(icon.element, 'gl-tooltip'); + expect(icon.props('icon')).toBe('warning'); + expect(tooltip.value).toMatchObject({ + title: 'Invalid Package: failed metadata extraction', + }); + }); + + it('delete button does not exist', () => { + expect(findDeleteButton().exists()).toBe(false); + }); + }); + + describe('secondary left info', () => { + it('has the package version', () => { + mountComponent(); + + expect(findLeftSecondaryInfos().text()).toContain(packageWithoutTags.version); + }); + + it('if the pipeline exists show the author message', () => { + mountComponent({ + packageEntity: { ...packageWithoutTags, pipelines: { nodes: packagePipelines() } }, + }); + + expect(findLeftSecondaryInfos().text()).toContain('published by Administrator'); + }); + + it('has icon and name component', () => { + mountComponent(); + + expect(findPackageIconAndName().text()).toBe(packageWithoutTags.packageType.toLowerCase()); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_app_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_app_spec.js deleted file mode 100644 index 6c871a34d50..00000000000 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_app_spec.js +++ /dev/null @@ -1,273 +0,0 @@ -import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import setWindowLocation from 'helpers/set_window_location_helper'; -import createFlash from '~/flash'; -import * as commonUtils from '~/lib/utils/common_utils'; -import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants'; -import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; -import PackageListApp from '~/packages_and_registries/package_registry/components/list/packages_list_app.vue'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; -import * as packageUtils from '~/packages_and_registries/shared/utils'; - -jest.mock('~/lib/utils/common_utils'); -jest.mock('~/flash'); - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('packages_list_app', () => { - let wrapper; - let store; - - const PackageList = { - name: 'package-list', - template: '<div><slot name="empty-state"></slot></div>', - }; - const GlLoadingIcon = { name: 'gl-loading-icon', template: '<div>loading</div>' }; - - // we need to manually stub dynamic imported components because shallowMount is not able to stub them automatically. See: https://github.com/vuejs/vue-test-utils/issues/1279 - const PackageSearch = { name: 'PackageSearch', template: '<div></div>' }; - const PackageTitle = { name: 'PackageTitle', template: '<div></div>' }; - const InfrastructureTitle = { name: 'InfrastructureTitle', template: '<div></div>' }; - const InfrastructureSearch = { name: 'InfrastructureSearch', template: '<div></div>' }; - - const emptyListHelpUrl = 'helpUrl'; - const findEmptyState = () => wrapper.find(GlEmptyState); - const findListComponent = () => wrapper.find(PackageList); - const findPackageSearch = () => wrapper.find(PackageSearch); - const findPackageTitle = () => wrapper.find(PackageTitle); - const findInfrastructureTitle = () => wrapper.find(InfrastructureTitle); - const findInfrastructureSearch = () => wrapper.find(InfrastructureSearch); - - const createStore = (filter = []) => { - store = new Vuex.Store({ - state: { - isLoading: false, - config: { - resourceId: 'project_id', - emptyListIllustration: 'helpSvg', - emptyListHelpUrl, - packageHelpUrl: 'foo', - }, - filter, - }, - }); - store.dispatch = jest.fn(); - }; - - const mountComponent = (provide) => { - wrapper = shallowMount(PackageListApp, { - localVue, - store, - stubs: { - GlEmptyState, - GlLoadingIcon, - PackageList, - GlSprintf, - GlLink, - PackageSearch, - PackageTitle, - InfrastructureTitle, - InfrastructureSearch, - }, - provide, - }); - }; - - beforeEach(() => { - createStore(); - jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue({}); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders', () => { - mountComponent(); - expect(wrapper.element).toMatchSnapshot(); - }); - - it('call requestPackagesList on page:changed', () => { - mountComponent(); - store.dispatch.mockClear(); - - const list = findListComponent(); - list.vm.$emit('page:changed', 1); - expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList', { page: 1 }); - }); - - it('call requestDeletePackage on package:delete', () => { - mountComponent(); - - const list = findListComponent(); - list.vm.$emit('package:delete', 'foo'); - expect(store.dispatch).toHaveBeenCalledWith('requestDeletePackage', 'foo'); - }); - - it('does call requestPackagesList only one time on render', () => { - mountComponent(); - - expect(store.dispatch).toHaveBeenCalledTimes(3); - expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', expect.any(Object)); - expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', expect.any(Array)); - expect(store.dispatch).toHaveBeenNthCalledWith(3, 'requestPackagesList'); - }); - - describe('url query string handling', () => { - const defaultQueryParamsMock = { - search: [1, 2], - type: 'npm', - sort: 'asc', - orderBy: 'created', - }; - - it('calls setSorting with the query string based sorting', () => { - jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue(defaultQueryParamsMock); - - mountComponent(); - - expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', { - orderBy: defaultQueryParamsMock.orderBy, - sort: defaultQueryParamsMock.sort, - }); - }); - - it('calls setFilter with the query string based filters', () => { - jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue(defaultQueryParamsMock); - - mountComponent(); - - expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', [ - { type: 'type', value: { data: defaultQueryParamsMock.type } }, - { type: FILTERED_SEARCH_TERM, value: { data: defaultQueryParamsMock.search[0] } }, - { type: FILTERED_SEARCH_TERM, value: { data: defaultQueryParamsMock.search[1] } }, - ]); - }); - - it('calls setSorting and setFilters with the results of extractFilterAndSorting', () => { - jest - .spyOn(packageUtils, 'extractFilterAndSorting') - .mockReturnValue({ filters: ['foo'], sorting: { sort: 'desc' } }); - - mountComponent(); - - expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', { sort: 'desc' }); - expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', ['foo']); - }); - }); - - describe('empty state', () => { - it('generate the correct empty list link', () => { - mountComponent(); - - const link = findListComponent().find(GlLink); - - expect(link.attributes('href')).toBe(emptyListHelpUrl); - expect(link.text()).toBe('publish and share your packages'); - }); - - it('includes the right content on the default tab', () => { - mountComponent(); - - const heading = findEmptyState().find('h1'); - - expect(heading.text()).toBe('There are no packages yet'); - }); - }); - - describe('filter without results', () => { - beforeEach(() => { - createStore([{ type: 'something' }]); - mountComponent(); - }); - - it('should show specific empty message', () => { - expect(findEmptyState().text()).toContain('Sorry, your filter produced no results'); - expect(findEmptyState().text()).toContain( - 'To widen your search, change or remove the filters above', - ); - }); - }); - - describe('Package Search', () => { - it('exists', () => { - mountComponent(); - - expect(findPackageSearch().exists()).toBe(true); - }); - - it('on update fetches data from the store', () => { - mountComponent(); - store.dispatch.mockClear(); - - findPackageSearch().vm.$emit('update'); - - expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList'); - }); - }); - - describe('Infrastructure config', () => { - it('defaults to package registry components', () => { - mountComponent(); - - expect(findPackageSearch().exists()).toBe(true); - expect(findPackageTitle().exists()).toBe(true); - - expect(findInfrastructureTitle().exists()).toBe(false); - expect(findInfrastructureSearch().exists()).toBe(false); - }); - - it('mount different component based on the provided values', () => { - mountComponent({ - titleComponent: 'InfrastructureTitle', - searchComponent: 'InfrastructureSearch', - }); - - expect(findPackageSearch().exists()).toBe(false); - expect(findPackageTitle().exists()).toBe(false); - - expect(findInfrastructureTitle().exists()).toBe(true); - expect(findInfrastructureSearch().exists()).toBe(true); - }); - }); - - describe('delete alert handling', () => { - const originalLocation = window.location.href; - const search = `?${SHOW_DELETE_SUCCESS_ALERT}=true`; - - beforeEach(() => { - createStore(); - jest.spyOn(commonUtils, 'historyReplaceState').mockImplementation(() => {}); - setWindowLocation(search); - }); - - afterEach(() => { - setWindowLocation(originalLocation); - }); - - it(`creates a flash if the query string contains ${SHOW_DELETE_SUCCESS_ALERT}`, () => { - mountComponent(); - - expect(createFlash).toHaveBeenCalledWith({ - message: DELETE_PACKAGE_SUCCESS_MESSAGE, - type: 'notice', - }); - }); - - it('calls historyReplaceState with a clean url', () => { - mountComponent(); - - expect(commonUtils.historyReplaceState).toHaveBeenCalledWith(originalLocation); - }); - - it(`does nothing if the query string does not contain ${SHOW_DELETE_SUCCESS_ALERT}`, () => { - setWindowLocation('?'); - mountComponent(); - - expect(createFlash).not.toHaveBeenCalled(); - expect(commonUtils.historyReplaceState).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js index 42bc9fa3a9e..e65b2a6f320 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js @@ -1,79 +1,79 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { sortableFields } from '~/packages/list/utils'; import component from '~/packages_and_registries/package_registry/components/list/package_search.vue'; import PackageTypeToken from '~/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; -const localVue = createLocalVue(); -localVue.use(Vuex); +jest.mock('~/packages_and_registries/shared/utils'); + +useMockLocationHelper(); describe('Package Search', () => { let wrapper; - let store; + + const defaultQueryParamsMock = { + filters: ['foo'], + sorting: { sort: 'desc' }, + }; const findRegistrySearch = () => wrapper.findComponent(RegistrySearch); const findUrlSync = () => wrapper.findComponent(UrlSync); - const createStore = (isGroupPage) => { - const state = { - config: { - isGroupPage, - }, - sorting: { - orderBy: 'version', - sort: 'desc', - }, - filter: [], - }; - store = new Vuex.Store({ - state, - }); - store.dispatch = jest.fn(); - }; - const mountComponent = (isGroupPage = false) => { - createStore(isGroupPage); - - wrapper = shallowMount(component, { - localVue, - store, + wrapper = shallowMountExtended(component, { + provide() { + return { + isGroupPage, + }; + }, stubs: { UrlSync, }, }); }; + beforeEach(() => { + extractFilterAndSorting.mockReturnValue(defaultQueryParamsMock); + }); + afterEach(() => { wrapper.destroy(); - wrapper = null; }); - it('has a registry search component', () => { + it('has a registry search component', async () => { mountComponent(); + await nextTick(); + expect(findRegistrySearch().exists()).toBe(true); - expect(findRegistrySearch().props()).toMatchObject({ - filter: store.state.filter, - sorting: store.state.sorting, - tokens: expect.arrayContaining([ - expect.objectContaining({ token: PackageTypeToken, type: 'type', icon: 'package' }), - ]), - sortableFields: sortableFields(), - }); + }); + + it('registry search is mounted after mount', async () => { + mountComponent(); + + expect(findRegistrySearch().exists()).toBe(false); + }); + + it('has a UrlSync component', () => { + mountComponent(); + + expect(findUrlSync().exists()).toBe(true); }); it.each` isGroupPage | page ${false} | ${'project'} ${true} | ${'group'} - `('in a $page page binds the right props', ({ isGroupPage }) => { + `('in a $page page binds the right props', async ({ isGroupPage }) => { mountComponent(isGroupPage); + await nextTick(); + expect(findRegistrySearch().props()).toMatchObject({ - filter: store.state.filter, - sorting: store.state.sorting, tokens: expect.arrayContaining([ expect.objectContaining({ token: PackageTypeToken, type: 'type', icon: 'package' }), ]), @@ -81,48 +81,85 @@ describe('Package Search', () => { }); }); - it('on sorting:changed emits update event and calls vuex setSorting', () => { + it('on sorting:changed emits update event and update internal sort', async () => { const payload = { sort: 'foo' }; mountComponent(); + await nextTick(); + findRegistrySearch().vm.$emit('sorting:changed', payload); - expect(store.dispatch).toHaveBeenCalledWith('setSorting', payload); - expect(wrapper.emitted('update')).toEqual([[]]); + await nextTick(); + + expect(findRegistrySearch().props('sorting')).toEqual({ sort: 'foo', orderBy: 'name' }); + + // there is always a first call on mounted that emits up default values + expect(wrapper.emitted('update')[1]).toEqual([ + { + filters: { + packageName: '', + packageType: undefined, + }, + sort: 'NAME_FOO', + }, + ]); }); - it('on filter:changed calls vuex setFilter', () => { + it('on filter:changed updates the filters', async () => { const payload = ['foo']; mountComponent(); + await nextTick(); + findRegistrySearch().vm.$emit('filter:changed', payload); - expect(store.dispatch).toHaveBeenCalledWith('setFilter', payload); + await nextTick(); + + expect(findRegistrySearch().props('filter')).toEqual(['foo']); }); - it('on filter:submit emits update event', () => { + it('on filter:submit emits update event', async () => { mountComponent(); - findRegistrySearch().vm.$emit('filter:submit'); - - expect(wrapper.emitted('update')).toEqual([[]]); - }); + await nextTick(); - it('has a UrlSync component', () => { - mountComponent(); + findRegistrySearch().vm.$emit('filter:submit'); - expect(findUrlSync().exists()).toBe(true); + expect(wrapper.emitted('update')[1]).toEqual([ + { + filters: { + packageName: '', + packageType: undefined, + }, + sort: 'NAME_DESC', + }, + ]); }); - it('on query:changed calls updateQuery from UrlSync', () => { + it('on query:changed calls updateQuery from UrlSync', async () => { jest.spyOn(UrlSync.methods, 'updateQuery').mockImplementation(() => {}); mountComponent(); + await nextTick(); + findRegistrySearch().vm.$emit('query:changed'); expect(UrlSync.methods.updateQuery).toHaveBeenCalled(); }); + + it('sets the component sorting and filtering based on the querystring', async () => { + mountComponent(); + + await nextTick(); + + expect(getQueryParams).toHaveBeenCalled(); + + expect(findRegistrySearch().props()).toMatchObject({ + filter: defaultQueryParamsMock.filters, + sorting: defaultQueryParamsMock.sorting, + }); + }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/publish_method_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/publish_method_spec.js new file mode 100644 index 00000000000..fcbd7cc6a50 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/publish_method_spec.js @@ -0,0 +1,47 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PublishMethod from '~/packages_and_registries/package_registry/components/list/publish_method.vue'; +import { packagePipelines } from '../../mock_data'; + +const [pipelineData] = packagePipelines(); + +describe('publish_method', () => { + let wrapper; + + const findPipelineRef = () => wrapper.findByTestId('pipeline-ref'); + const findPipelineSha = () => wrapper.findByTestId('pipeline-sha'); + const findManualPublish = () => wrapper.findByTestId('manually-published'); + + const mountComponent = (pipeline = pipelineData) => { + wrapper = shallowMountExtended(PublishMethod, { + propsData: { + pipeline, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + mountComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('pipeline information', () => { + it('displays branch and commit when pipeline info exists', () => { + mountComponent(); + + expect(findPipelineRef().exists()).toBe(true); + expect(findPipelineSha().exists()).toBe(true); + }); + + it('does not show any pipeline details when no information exists', () => { + mountComponent(null); + + expect(findPipelineRef().exists()).toBe(false); + expect(findPipelineSha().exists()).toBe(false); + expect(findManualPublish().text()).toBe(PublishMethod.i18n.MANUALLY_PUBLISHED); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index 9438a2d2d72..70fc096fa44 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -86,6 +86,12 @@ export const dependencyLinks = () => [ }, ]; +export const packageProject = () => ({ + fullPath: 'gitlab-org/gitlab-test', + webUrl: 'http://gdk.test:3000/gitlab-org/gitlab-test', + __typename: 'Project', +}); + export const packageVersions = () => [ { createdAt: '2021-08-10T09:33:54Z', @@ -249,3 +255,31 @@ export const packageDestroyFileMutationError = () => ({ }, ], }); + +export const packagesListQuery = (type = 'group') => ({ + data: { + [type]: { + packages: { + count: 2, + nodes: [ + { + ...packageData(), + project: packageProject(), + tags: { nodes: packageTags() }, + pipelines: { + nodes: packagePipelines(), + }, + }, + { + ...packageData(), + project: packageProject(), + tags: { nodes: [] }, + pipelines: { nodes: [] }, + }, + ], + __typename: 'PackageConnection', + }, + __typename: 'Group', + }, + }, +}); diff --git a/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js new file mode 100644 index 00000000000..d3a970e86eb --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js @@ -0,0 +1,189 @@ +import { GlSprintf, GlLink, GlToggle } 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 component from '~/packages_and_registries/settings/group/components/dependency_proxy_settings.vue'; +import { + DEPENDENCY_PROXY_HEADER, + DEPENDENCY_PROXY_SETTINGS_DESCRIPTION, + DEPENDENCY_PROXY_DOCS_PATH, +} from '~/packages_and_registries/settings/group/constants'; + +import updateDependencyProxySettings from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql'; +import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; +import { updateGroupDependencyProxySettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; +import { + dependencyProxySettings, + dependencyProxySettingMutationMock, + groupPackageSettingsMock, + dependencyProxySettingMutationErrorMock, +} from '../mock_data'; + +jest.mock('~/flash'); +jest.mock('~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'); + +const localVue = createLocalVue(); + +describe('DependencyProxySettings', () => { + let wrapper; + let apolloProvider; + + const defaultProvide = { + defaultExpanded: false, + groupPath: 'foo_group_path', + }; + + localVue.use(VueApollo); + + const mountComponent = ({ + provide = defaultProvide, + mutationResolver = jest.fn().mockResolvedValue(dependencyProxySettingMutationMock()), + isLoading = false, + } = {}) => { + const requestHandlers = [[updateDependencyProxySettings, mutationResolver]]; + + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMount(component, { + localVue, + apolloProvider, + provide, + propsData: { + dependencyProxySettings: dependencyProxySettings(), + isLoading, + }, + stubs: { + GlSprintf, + SettingsBlock, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); + const findDescription = () => wrapper.find('[data-testid="description"'); + const findLink = () => wrapper.findComponent(GlLink); + const findToggle = () => wrapper.findComponent(GlToggle); + + const fillApolloCache = () => { + apolloProvider.defaultClient.cache.writeQuery({ + query: getGroupPackagesSettingsQuery, + variables: { + fullPath: defaultProvide.groupPath, + }, + ...groupPackageSettingsMock, + }); + }; + + const emitSettingsUpdate = (value = false) => { + findToggle().vm.$emit('change', value); + }; + + it('renders a settings block', () => { + mountComponent(); + + expect(findSettingsBlock().exists()).toBe(true); + }); + + it('passes the correct props to settings block', () => { + mountComponent(); + + expect(findSettingsBlock().props('defaultExpanded')).toBe(false); + }); + + it('has the correct header text', () => { + mountComponent(); + + expect(wrapper.text()).toContain(DEPENDENCY_PROXY_HEADER); + }); + + it('has the correct description text', () => { + mountComponent(); + + expect(findDescription().text()).toMatchInterpolatedText(DEPENDENCY_PROXY_SETTINGS_DESCRIPTION); + }); + + it('has the correct link', () => { + mountComponent(); + + expect(findLink().attributes()).toMatchObject({ + href: DEPENDENCY_PROXY_DOCS_PATH, + }); + expect(findLink().text()).toBe('Learn more'); + }); + + describe('settings update', () => { + describe('success state', () => { + it('emits a success event', async () => { + mountComponent(); + + fillApolloCache(); + emitSettingsUpdate(); + + await waitForPromises(); + + expect(wrapper.emitted('success')).toEqual([[]]); + }); + + it('has an optimistic response', () => { + mountComponent(); + + fillApolloCache(); + + expect(findToggle().props('value')).toBe(true); + + emitSettingsUpdate(); + + expect(updateGroupDependencyProxySettingsOptimisticResponse).toHaveBeenCalledWith({ + enabled: false, + }); + }); + }); + + describe('errors', () => { + it('mutation payload with root level errors', async () => { + const mutationResolver = jest + .fn() + .mockResolvedValue(dependencyProxySettingMutationErrorMock); + mountComponent({ mutationResolver }); + + fillApolloCache(); + + emitSettingsUpdate(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[]]); + }); + + it.each` + type | mutationResolver + ${'local'} | ${jest.fn().mockResolvedValue(dependencyProxySettingMutationMock({ errors: ['foo'] }))} + ${'network'} | ${jest.fn().mockRejectedValue()} + `('mutation payload with $type error', async ({ mutationResolver }) => { + mountComponent({ mutationResolver }); + + fillApolloCache(); + emitSettingsUpdate(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[]]); + }); + }); + }); + + describe('when isLoading is true', () => { + it('disables enable toggle', () => { + mountComponent({ isLoading: true }); + + expect(findToggle().props('disabled')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js index 0bbb1ce3436..79c2f811c08 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js @@ -143,4 +143,18 @@ describe('Duplicates Settings', () => { expect(findInputGroup().exists()).toBe(false); }); }); + + describe('loading', () => { + beforeEach(() => { + mountComponent({ ...defaultProps, loading: true }); + }); + + it('disables the enable toggle', () => { + expect(findToggle().props('disabled')).toBe(true); + }); + + it('disables the form input', () => { + expect(findInput().attributes('disabled')).toBe('true'); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js index f2877a1f2a5..e4d62bc6a6e 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js @@ -1,28 +1,16 @@ -import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; +import { GlAlert } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; +import { nextTick } from 'vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue'; -import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue'; +import PackagesSettings from '~/packages_and_registries/settings/group/components/packages_settings.vue'; +import DependencyProxySettings from '~/packages_and_registries/settings/group/components/dependency_proxy_settings.vue'; + import component from '~/packages_and_registries/settings/group/components/group_settings_app.vue'; -import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue'; -import { - PACKAGE_SETTINGS_HEADER, - PACKAGE_SETTINGS_DESCRIPTION, - PACKAGES_DOCS_PATH, - ERROR_UPDATING_SETTINGS, - SUCCESS_UPDATING_SETTINGS, -} from '~/packages_and_registries/settings/group/constants'; -import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql'; -import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; -import { - groupPackageSettingsMock, - groupPackageSettingsMutationMock, - groupPackageSettingsMutationErrorMock, -} from '../mock_data'; +import { groupPackageSettingsMock, packageSettings, dependencyProxySettings } from '../mock_data'; jest.mock('~/flash'); @@ -36,20 +24,16 @@ describe('Group Settings App', () => { const defaultProvide = { defaultExpanded: false, groupPath: 'foo_group_path', + dependencyProxyAvailable: true, }; const mountComponent = ({ - provide = defaultProvide, resolver = jest.fn().mockResolvedValue(groupPackageSettingsMock), - mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()), - data = {}, + provide = defaultProvide, } = {}) => { localVue.use(VueApollo); - const requestHandlers = [ - [getGroupPackagesSettingsQuery, resolver], - [updateNamespacePackageSettings, mutationResolver], - ]; + const requestHandlers = [[getGroupPackagesSettingsQuery, resolver]]; apolloProvider = createMockApollo(requestHandlers); @@ -57,17 +41,6 @@ describe('Group Settings App', () => { localVue, apolloProvider, provide, - data() { - return { - ...data, - }; - }, - stubs: { - GlSprintf, - SettingsBlock, - MavenSettings, - GenericSettings, - }, mocks: { $toast: { show, @@ -84,274 +57,89 @@ describe('Group Settings App', () => { wrapper.destroy(); }); - const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); - const findDescription = () => wrapper.find('[data-testid="description"'); - const findLink = () => wrapper.findComponent(GlLink); const findAlert = () => wrapper.findComponent(GlAlert); - const findMavenSettings = () => wrapper.findComponent(MavenSettings); - const findMavenDuplicatedSettings = () => findMavenSettings().findComponent(DuplicatesSettings); - const findGenericSettings = () => wrapper.findComponent(GenericSettings); - const findGenericDuplicatedSettings = () => - findGenericSettings().findComponent(DuplicatesSettings); + const findPackageSettings = () => wrapper.findComponent(PackagesSettings); + const findDependencyProxySettings = () => wrapper.findComponent(DependencyProxySettings); const waitForApolloQueryAndRender = async () => { await waitForPromises(); - await wrapper.vm.$nextTick(); - }; - - const emitSettingsUpdate = (override) => { - findMavenDuplicatedSettings().vm.$emit('update', { - mavenDuplicateExceptionRegex: ')', - ...override, - }); + await nextTick(); }; - it('renders a settings block', () => { - mountComponent(); - - expect(findSettingsBlock().exists()).toBe(true); - }); - - it('passes the correct props to settings block', () => { - mountComponent(); - - expect(findSettingsBlock().props('defaultExpanded')).toBe(false); - }); - - it('has the correct header text', () => { - mountComponent(); - - expect(wrapper.text()).toContain(PACKAGE_SETTINGS_HEADER); - }); - - it('has the correct description text', () => { - mountComponent(); - - expect(findDescription().text()).toMatchInterpolatedText(PACKAGE_SETTINGS_DESCRIPTION); - }); - - it('has the correct link', () => { - mountComponent(); - - expect(findLink().attributes()).toMatchObject({ - href: PACKAGES_DOCS_PATH, - target: '_blank', - }); - expect(findLink().text()).toBe('Learn more.'); - }); - - it('calls the graphql API with the proper variables', () => { - const resolver = jest.fn().mockResolvedValue(groupPackageSettingsMock); - mountComponent({ resolver }); - - expect(resolver).toHaveBeenCalledWith({ - fullPath: defaultProvide.groupPath, - }); - }); - - describe('maven settings', () => { - it('exists', () => { + describe.each` + finder | entityProp | entityValue | successMessage | errorMessage + ${findPackageSettings} | ${'packageSettings'} | ${packageSettings()} | ${'Settings saved successfully'} | ${'An error occurred while saving the settings'} + ${findDependencyProxySettings} | ${'dependencyProxySettings'} | ${dependencyProxySettings()} | ${'Setting saved successfully'} | ${'An error occurred while saving the setting'} + `('settings blocks', ({ finder, entityProp, entityValue, successMessage, errorMessage }) => { + beforeEach(() => { mountComponent(); - - expect(findMavenSettings().exists()).toBe(true); + return waitForApolloQueryAndRender(); }); - it('assigns duplication allowness and exception props', async () => { - mountComponent(); - - expect(findMavenDuplicatedSettings().props('loading')).toBe(true); - - await waitForApolloQueryAndRender(); - - const { - mavenDuplicatesAllowed, - mavenDuplicateExceptionRegex, - } = groupPackageSettingsMock.data.group.packageSettings; - - expect(findMavenDuplicatedSettings().props()).toMatchObject({ - duplicatesAllowed: mavenDuplicatesAllowed, - duplicateExceptionRegex: mavenDuplicateExceptionRegex, - duplicateExceptionRegexError: '', - loading: false, - }); + it('renders the settings block', () => { + expect(finder().exists()).toBe(true); }); - it('on update event calls the mutation', async () => { - const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()); - mountComponent({ mutationResolver }); - - await waitForApolloQueryAndRender(); - - emitSettingsUpdate(); - - expect(mutationResolver).toHaveBeenCalledWith({ - input: { mavenDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' }, + it('binds the correctProps', () => { + expect(finder().props()).toMatchObject({ + isLoading: false, + [entityProp]: entityValue, }); }); - }); - - describe('generic settings', () => { - it('exists', () => { - mountComponent(); - - expect(findGenericSettings().exists()).toBe(true); - }); - - it('assigns duplication allowness and exception props', async () => { - mountComponent(); - - expect(findGenericDuplicatedSettings().props('loading')).toBe(true); - - await waitForApolloQueryAndRender(); - - const { - genericDuplicatesAllowed, - genericDuplicateExceptionRegex, - } = groupPackageSettingsMock.data.group.packageSettings; - expect(findGenericDuplicatedSettings().props()).toMatchObject({ - duplicatesAllowed: genericDuplicatesAllowed, - duplicateExceptionRegex: genericDuplicateExceptionRegex, - duplicateExceptionRegexError: '', - loading: false, + describe('success event', () => { + it('shows a success toast', () => { + finder().vm.$emit('success'); + expect(show).toHaveBeenCalledWith(successMessage); }); - }); - it('on update event calls the mutation', async () => { - const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()); - mountComponent({ mutationResolver }); + it('hides the error alert', async () => { + finder().vm.$emit('error'); + await nextTick(); - await waitForApolloQueryAndRender(); + expect(findAlert().exists()).toBe(true); - findMavenDuplicatedSettings().vm.$emit('update', { - genericDuplicateExceptionRegex: ')', - }); + finder().vm.$emit('success'); + await nextTick(); - expect(mutationResolver).toHaveBeenCalledWith({ - input: { genericDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' }, + expect(findAlert().exists()).toBe(false); }); }); - }); - - describe('settings update', () => { - describe('success state', () => { - it('shows a success alert', async () => { - mountComponent(); - - await waitForApolloQueryAndRender(); - - emitSettingsUpdate(); - - await waitForPromises(); - - expect(show).toHaveBeenCalledWith(SUCCESS_UPDATING_SETTINGS); - }); - - it('has an optimistic response', async () => { - const mavenDuplicateExceptionRegex = 'latest[main]something'; - mountComponent(); - await waitForApolloQueryAndRender(); - - expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe(''); - - emitSettingsUpdate({ mavenDuplicateExceptionRegex }); - - // wait for apollo to update the model with the optimistic response - await wrapper.vm.$nextTick(); - - expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe( - mavenDuplicateExceptionRegex, - ); - - // wait for the call to resolve - await waitForPromises(); - - expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe( - mavenDuplicateExceptionRegex, - ); + describe('error event', () => { + beforeEach(() => { + finder().vm.$emit('error'); + return nextTick(); }); - }); - describe('errors', () => { - const verifyAlert = () => { + it('shows an alert', () => { expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(ERROR_UPDATING_SETTINGS); - expect(findAlert().props('variant')).toBe('warning'); - }; - - it('mutation payload with root level errors', async () => { - // note this is a complex test that covers all the path around errors that are shown in the form - // it's one single it case, due to the expensive preparation and execution - const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationErrorMock); - mountComponent({ mutationResolver }); - - await waitForApolloQueryAndRender(); - - emitSettingsUpdate(); - - await waitForApolloQueryAndRender(); - - // errors are bound to the component - expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe( - groupPackageSettingsMutationErrorMock.errors[0].extensions.problems[0].message, - ); - - // general error message is shown - - verifyAlert(); - - emitSettingsUpdate(); - - await wrapper.vm.$nextTick(); - - // errors are reset on mutation call - expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe(''); }); - it.each` - type | mutationResolver - ${'local'} | ${jest.fn().mockResolvedValue(groupPackageSettingsMutationMock({ errors: ['foo'] }))} - ${'network'} | ${jest.fn().mockRejectedValue()} - `('mutation payload with $type error', async ({ mutationResolver }) => { - mountComponent({ mutationResolver }); - - await waitForApolloQueryAndRender(); - - emitSettingsUpdate(); - - await waitForPromises(); - - verifyAlert(); + it('alert has the right text', () => { + expect(findAlert().text()).toBe(errorMessage); }); - it('a successful request dismisses the alert', async () => { - mountComponent({ data: { alertMessage: 'foo' } }); - - await waitForApolloQueryAndRender(); - + it('dismissing the alert removes it', async () => { expect(findAlert().exists()).toBe(true); - emitSettingsUpdate(); + findAlert().vm.$emit('dismiss'); - await waitForPromises(); + await nextTick(); expect(findAlert().exists()).toBe(false); }); + }); + }); - it('dismiss event from alert dismiss it from the page', async () => { - mountComponent({ data: { alertMessage: 'foo' } }); - - await waitForApolloQueryAndRender(); - - expect(findAlert().exists()).toBe(true); - - findAlert().vm.$emit('dismiss'); - - await wrapper.vm.$nextTick(); + describe('when the dependency proxy is not available', () => { + beforeEach(() => { + mountComponent({ provide: { ...defaultProvide, dependencyProxyAvailable: false } }); + return waitForApolloQueryAndRender(); + }); - expect(findAlert().exists()).toBe(false); - }); + it('the setting block is hidden', () => { + expect(findDependencyProxySettings().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js new file mode 100644 index 00000000000..693af21e24a --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js @@ -0,0 +1,277 @@ +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue'; +import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue'; +import component from '~/packages_and_registries/settings/group/components/packages_settings.vue'; +import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue'; +import { + PACKAGE_SETTINGS_HEADER, + PACKAGE_SETTINGS_DESCRIPTION, + PACKAGES_DOCS_PATH, +} from '~/packages_and_registries/settings/group/constants'; + +import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; +import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; +import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; +import { + packageSettings, + groupPackageSettingsMock, + groupPackageSettingsMutationMock, + groupPackageSettingsMutationErrorMock, +} from '../mock_data'; + +jest.mock('~/flash'); +jest.mock('~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'); + +const localVue = createLocalVue(); + +describe('Packages Settings', () => { + let wrapper; + let apolloProvider; + + const defaultProvide = { + defaultExpanded: false, + groupPath: 'foo_group_path', + }; + + const mountComponent = ({ + mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()), + } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [[updateNamespacePackageSettings, mutationResolver]]; + + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMountExtended(component, { + localVue, + apolloProvider, + provide: defaultProvide, + propsData: { + packageSettings: packageSettings(), + }, + stubs: { + GlSprintf, + SettingsBlock, + MavenSettings, + GenericSettings, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); + const findDescription = () => wrapper.findByTestId('description'); + const findLink = () => wrapper.findComponent(GlLink); + const findMavenSettings = () => wrapper.findComponent(MavenSettings); + const findMavenDuplicatedSettings = () => findMavenSettings().findComponent(DuplicatesSettings); + const findGenericSettings = () => wrapper.findComponent(GenericSettings); + const findGenericDuplicatedSettings = () => + findGenericSettings().findComponent(DuplicatesSettings); + + const fillApolloCache = () => { + apolloProvider.defaultClient.cache.writeQuery({ + query: getGroupPackagesSettingsQuery, + variables: { + fullPath: defaultProvide.groupPath, + }, + ...groupPackageSettingsMock, + }); + }; + + const emitMavenSettingsUpdate = (override) => { + findMavenDuplicatedSettings().vm.$emit('update', { + mavenDuplicateExceptionRegex: ')', + ...override, + }); + }; + + it('renders a settings block', () => { + mountComponent(); + + expect(findSettingsBlock().exists()).toBe(true); + }); + + it('passes the correct props to settings block', () => { + mountComponent(); + + expect(findSettingsBlock().props('defaultExpanded')).toBe(false); + }); + + it('has the correct header text', () => { + mountComponent(); + + expect(wrapper.text()).toContain(PACKAGE_SETTINGS_HEADER); + }); + + it('has the correct description text', () => { + mountComponent(); + + expect(findDescription().text()).toMatchInterpolatedText(PACKAGE_SETTINGS_DESCRIPTION); + }); + + it('has the correct link', () => { + mountComponent(); + + expect(findLink().attributes()).toMatchObject({ + href: PACKAGES_DOCS_PATH, + target: '_blank', + }); + expect(findLink().text()).toBe('Learn more.'); + }); + + describe('maven settings', () => { + it('exists', () => { + mountComponent(); + + expect(findMavenSettings().exists()).toBe(true); + }); + + it('assigns duplication allowness and exception props', async () => { + mountComponent(); + + const { mavenDuplicatesAllowed, mavenDuplicateExceptionRegex } = packageSettings(); + + expect(findMavenDuplicatedSettings().props()).toMatchObject({ + duplicatesAllowed: mavenDuplicatesAllowed, + duplicateExceptionRegex: mavenDuplicateExceptionRegex, + duplicateExceptionRegexError: '', + loading: false, + }); + }); + + it('on update event calls the mutation', () => { + const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()); + mountComponent({ mutationResolver }); + + fillApolloCache(); + + emitMavenSettingsUpdate(); + + expect(mutationResolver).toHaveBeenCalledWith({ + input: { mavenDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' }, + }); + }); + }); + + describe('generic settings', () => { + it('exists', () => { + mountComponent(); + + expect(findGenericSettings().exists()).toBe(true); + }); + + it('assigns duplication allowness and exception props', async () => { + mountComponent(); + + const { genericDuplicatesAllowed, genericDuplicateExceptionRegex } = packageSettings(); + + expect(findGenericDuplicatedSettings().props()).toMatchObject({ + duplicatesAllowed: genericDuplicatesAllowed, + duplicateExceptionRegex: genericDuplicateExceptionRegex, + duplicateExceptionRegexError: '', + loading: false, + }); + }); + + it('on update event calls the mutation', async () => { + const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()); + mountComponent({ mutationResolver }); + + fillApolloCache(); + + findMavenDuplicatedSettings().vm.$emit('update', { + genericDuplicateExceptionRegex: ')', + }); + + expect(mutationResolver).toHaveBeenCalledWith({ + input: { genericDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' }, + }); + }); + }); + + describe('settings update', () => { + describe('success state', () => { + it('emits a success event', async () => { + mountComponent(); + + fillApolloCache(); + emitMavenSettingsUpdate(); + + await waitForPromises(); + + expect(wrapper.emitted('success')).toEqual([[]]); + }); + + it('has an optimistic response', () => { + const mavenDuplicateExceptionRegex = 'latest[main]something'; + mountComponent(); + + fillApolloCache(); + + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe(''); + + emitMavenSettingsUpdate({ mavenDuplicateExceptionRegex }); + + expect(updateGroupPackagesSettingsOptimisticResponse).toHaveBeenCalledWith({ + ...packageSettings(), + mavenDuplicateExceptionRegex, + }); + }); + }); + + describe('errors', () => { + it('mutation payload with root level errors', async () => { + // note this is a complex test that covers all the path around errors that are shown in the form + // it's one single it case, due to the expensive preparation and execution + const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationErrorMock); + mountComponent({ mutationResolver }); + + fillApolloCache(); + + emitMavenSettingsUpdate(); + + await waitForPromises(); + + // errors are bound to the component + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe( + groupPackageSettingsMutationErrorMock.errors[0].extensions.problems[0].message, + ); + + // general error message is shown + + expect(wrapper.emitted('error')).toEqual([[]]); + + emitMavenSettingsUpdate(); + + await wrapper.vm.$nextTick(); + + // errors are reset on mutation call + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe(''); + }); + + it.each` + type | mutationResolver + ${'local'} | ${jest.fn().mockResolvedValue(groupPackageSettingsMutationMock({ errors: ['foo'] }))} + ${'network'} | ${jest.fn().mockRejectedValue()} + `('mutation payload with $type error', async ({ mutationResolver }) => { + mountComponent({ mutationResolver }); + + fillApolloCache(); + emitMavenSettingsUpdate(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[]]); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js b/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js index 03133bf1158..9d8504a1124 100644 --- a/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js @@ -4,14 +4,16 @@ import { updateGroupPackageSettings } from '~/packages_and_registries/settings/g describe('Package and Registries settings group cache updates', () => { let client; - const payload = { - data: { - updateNamespacePackageSettings: { - packageSettings: { - mavenDuplicatesAllowed: false, - mavenDuplicateExceptionRegex: 'latest[main]something', - }, - }, + const updateNamespacePackageSettingsPayload = { + packageSettings: { + mavenDuplicatesAllowed: false, + mavenDuplicateExceptionRegex: 'latest[main]something', + }, + }; + + const updateDependencyProxySettingsPayload = { + dependencyProxySetting: { + enabled: false, }, }; @@ -21,6 +23,9 @@ describe('Package and Registries settings group cache updates', () => { mavenDuplicatesAllowed: true, mavenDuplicateExceptionRegex: '', }, + dependencyProxySetting: { + enabled: true, + }, }, }; @@ -35,22 +40,35 @@ describe('Package and Registries settings group cache updates', () => { writeQuery: jest.fn(), }; }); - describe('updateGroupPackageSettings', () => { - it('calls readQuery', () => { - updateGroupPackageSettings('foo')(client, payload); - expect(client.readQuery).toHaveBeenCalledWith(queryAndVariables); - }); - - it('writes the correct result in the cache', () => { - updateGroupPackageSettings('foo')(client, payload); - expect(client.writeQuery).toHaveBeenCalledWith({ - ...queryAndVariables, - data: { - group: { - ...payload.data.updateNamespacePackageSettings, + + describe.each` + updateNamespacePackageSettings | updateDependencyProxySettings + ${updateNamespacePackageSettingsPayload} | ${updateDependencyProxySettingsPayload} + ${undefined} | ${updateDependencyProxySettingsPayload} + ${updateNamespacePackageSettingsPayload} | ${undefined} + ${undefined} | ${undefined} + `( + 'updateGroupPackageSettings', + ({ updateNamespacePackageSettings, updateDependencyProxySettings }) => { + const payload = { data: { updateNamespacePackageSettings, updateDependencyProxySettings } }; + it('calls readQuery', () => { + updateGroupPackageSettings('foo')(client, payload); + expect(client.readQuery).toHaveBeenCalledWith(queryAndVariables); + }); + + it('writes the correct result in the cache', () => { + updateGroupPackageSettings('foo')(client, payload); + expect(client.writeQuery).toHaveBeenCalledWith({ + ...queryAndVariables, + data: { + group: { + ...cacheMock.group, + ...payload.data.updateNamespacePackageSettings, + ...payload.data.updateDependencyProxySettings, + }, }, - }, + }); }); - }); - }); + }, + ); }); diff --git a/spec/frontend/packages_and_registries/settings/group/graphl/utils/optimistic_responses_spec.js b/spec/frontend/packages_and_registries/settings/group/graphl/utils/optimistic_responses_spec.js index a3c53d5768a..debeb9aa89c 100644 --- a/spec/frontend/packages_and_registries/settings/group/graphl/utils/optimistic_responses_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/graphl/utils/optimistic_responses_spec.js @@ -1,4 +1,7 @@ -import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; +import { + updateGroupPackagesSettingsOptimisticResponse, + updateGroupDependencyProxySettingsOptimisticResponse, +} from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; describe('Optimistic responses', () => { describe('updateGroupPackagesSettingsOptimisticResponse', () => { @@ -17,4 +20,22 @@ describe('Optimistic responses', () => { `); }); }); + + describe('updateGroupDependencyProxySettingsOptimisticResponse', () => { + it('returns the correct structure', () => { + expect(updateGroupDependencyProxySettingsOptimisticResponse({ foo: 'bar' })) + .toMatchInlineSnapshot(` + Object { + "__typename": "Mutation", + "updateDependencyProxySettings": Object { + "__typename": "UpdateDependencyProxySettingsPayload", + "dependencyProxySetting": Object { + "foo": "bar", + }, + "errors": Array [], + }, + } + `); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/mock_data.js b/spec/frontend/packages_and_registries/settings/group/mock_data.js index 65119e288a1..81ba0795b7d 100644 --- a/spec/frontend/packages_and_registries/settings/group/mock_data.js +++ b/spec/frontend/packages_and_registries/settings/group/mock_data.js @@ -1,12 +1,20 @@ +export const packageSettings = () => ({ + mavenDuplicatesAllowed: true, + mavenDuplicateExceptionRegex: '', + genericDuplicatesAllowed: true, + genericDuplicateExceptionRegex: '', +}); + +export const dependencyProxySettings = () => ({ + enabled: true, +}); + export const groupPackageSettingsMock = { data: { group: { - packageSettings: { - mavenDuplicatesAllowed: true, - mavenDuplicateExceptionRegex: '', - genericDuplicatesAllowed: true, - genericDuplicateExceptionRegex: '', - }, + fullPath: 'foo_group_path', + packageSettings: packageSettings(), + dependencyProxySetting: dependencyProxySettings(), }, }, }; @@ -26,6 +34,16 @@ export const groupPackageSettingsMutationMock = (override) => ({ }, }); +export const dependencyProxySettingMutationMock = (override) => ({ + data: { + updateDependencyProxySettings: { + dependencyProxySetting: dependencyProxySettings(), + errors: [], + ...override, + }, + }, +}); + export const groupPackageSettingsMutationErrorMock = { errors: [ { @@ -50,3 +68,23 @@ export const groupPackageSettingsMutationErrorMock = { }, ], }; +export const dependencyProxySettingMutationErrorMock = { + errors: [ + { + message: 'Some error', + locations: [{ line: 1, column: 41 }], + extensions: { + value: { + enabled: 'gitlab-org', + }, + problems: [ + { + path: ['enabled'], + explanation: 'explaination', + message: 'message', + }, + ], + }, + }, + ], +}; diff --git a/spec/frontend/packages_and_registries/settings/project/settings/__snapshots__/utils_spec.js.snap b/spec/frontend/packages_and_registries/settings/project/settings/__snapshots__/utils_spec.js.snap index cf554717127..2719e917a9b 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/__snapshots__/utils_spec.js.snap +++ b/spec/frontend/packages_and_registries/settings/project/settings/__snapshots__/utils_spec.js.snap @@ -100,6 +100,12 @@ Array [ "variable": 30, }, Object { + "default": false, + "key": "SIXTY_DAYS", + "label": "60 days", + "variable": 60, + }, + Object { "default": true, "key": "NINETY_DAYS", "label": "90 days", diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap index 1009db46401..9938357ed24 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap @@ -44,7 +44,7 @@ exports[`Settings Form Keep Regex matches snapshot 1`] = ` exports[`Settings Form OlderThan matches snapshot 1`] = ` <expiration-dropdown-stub data-testid="older-than-dropdown" - formoptions="[object Object],[object Object],[object Object],[object Object],[object Object]" + formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" label="Remove tags older than:" name="older-than" value="FOURTEEN_DAYS" diff --git a/spec/frontend/pages/admin/projects/components/namespace_select_spec.js b/spec/frontend/pages/admin/projects/components/namespace_select_spec.js new file mode 100644 index 00000000000..c579aa2f2da --- /dev/null +++ b/spec/frontend/pages/admin/projects/components/namespace_select_spec.js @@ -0,0 +1,93 @@ +import { mount } from '@vue/test-utils'; +import Api from '~/api'; +import NamespaceSelect from '~/pages/admin/projects/components/namespace_select.vue'; + +describe('Dropdown select component', () => { + let wrapper; + + const mountDropdown = (propsData) => { + wrapper = mount(NamespaceSelect, { propsData }); + }; + + const findDropdownToggle = () => wrapper.find('button.dropdown-toggle'); + const findNamespaceInput = () => wrapper.find('[data-testid="hidden-input"]'); + const findFilterInput = () => wrapper.find('.namespace-search-box input'); + const findDropdownOption = (match) => { + const buttons = wrapper + .findAll('button.dropdown-item') + .filter((node) => node.text().match(match)); + return buttons.length ? buttons.at(0) : buttons; + }; + + const setFieldValue = async (field, value) => { + await field.setValue(value); + field.trigger('blur'); + }; + + beforeEach(() => { + setFixtures('<div class="test-container"></div>'); + + jest.spyOn(Api, 'namespaces').mockImplementation((_, callback) => + callback([ + { id: 10, kind: 'user', full_path: 'Administrator' }, + { id: 20, kind: 'group', full_path: 'GitLab Org' }, + ]), + ); + }); + + it('creates a hidden input if fieldName is provided', () => { + mountDropdown({ fieldName: 'namespace-input' }); + + expect(findNamespaceInput()).toExist(); + expect(findNamespaceInput().attributes('name')).toBe('namespace-input'); + }); + + describe('clicking dropdown options', () => { + it('retrieves namespaces based on filter query', async () => { + mountDropdown(); + + await setFieldValue(findFilterInput(), 'test'); + + expect(Api.namespaces).toHaveBeenCalledWith('test', expect.anything()); + }); + + it('updates the dropdown value based upon selection', async () => { + mountDropdown({ fieldName: 'namespace-input' }); + + // wait for dropdown options to populate + await wrapper.vm.$nextTick(); + + expect(findDropdownOption('user: Administrator')).toExist(); + expect(findDropdownOption('group: GitLab Org')).toExist(); + expect(findDropdownOption('group: Foobar')).not.toExist(); + + findDropdownOption('user: Administrator').trigger('click'); + await wrapper.vm.$nextTick(); + + expect(findNamespaceInput().attributes('value')).toBe('10'); + expect(findDropdownToggle().text()).toBe('user: Administrator'); + }); + + it('triggers a setNamespace event upon selection', async () => { + mountDropdown(); + + // wait for dropdown options to populate + await wrapper.vm.$nextTick(); + + findDropdownOption('group: GitLab Org').trigger('click'); + + expect(wrapper.emitted('setNamespace')).toHaveLength(1); + expect(wrapper.emitted('setNamespace')[0][0]).toBe(20); + }); + + it('displays "Any Namespace" option when showAny prop provided', () => { + mountDropdown({ showAny: true }); + expect(wrapper.text()).toContain('Any namespace'); + }); + + it('does not display "Any Namespace" option when showAny prop not provided', () => { + mountDropdown(); + expect(wrapper.text()).not.toContain('Any namespace'); + }); + }); +}); diff --git a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js new file mode 100644 index 00000000000..d6b394a42c6 --- /dev/null +++ b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js @@ -0,0 +1,175 @@ +import { GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import PaginationBar from '~/import_entities/components/pagination_bar.vue'; +import BulkImportsHistoryApp from '~/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; + +describe('BulkImportsHistoryApp', () => { + const API_URL = '/api/v4/bulk_imports/entities'; + + const DEFAULT_HEADERS = { + 'x-page': 1, + 'x-per-page': 20, + 'x-next-page': 2, + 'x-total': 22, + 'x-total-pages': 2, + 'x-prev-page': null, + }; + const DUMMY_RESPONSE = [ + { + id: 1, + bulk_import_id: 1, + status: 'finished', + source_full_path: 'top-level-group-12', + destination_name: 'top-level-group-12', + destination_namespace: 'h5bp', + created_at: '2021-07-08T10:03:44.743Z', + failures: [], + }, + { + id: 2, + bulk_import_id: 2, + status: 'failed', + source_full_path: 'autodevops-demo', + destination_name: 'autodevops-demo', + destination_namespace: 'flightjs', + parent_id: null, + namespace_id: null, + project_id: null, + created_at: '2021-07-13T12:52:26.664Z', + updated_at: '2021-07-13T13:34:49.403Z', + failures: [ + { + pipeline_class: 'BulkImports::Groups::Pipelines::GroupPipeline', + pipeline_step: 'loader', + exception_class: 'ActiveRecord::RecordNotUnique', + correlation_id_value: '01FAFYSYZ7XPF3P9NSMTS693SZ', + created_at: '2021-07-13T13:34:49.344Z', + }, + ], + }, + ]; + + let wrapper; + let mock; + + function createComponent({ shallow = true } = {}) { + const mountFn = shallow ? shallowMount : mount; + wrapper = mountFn(BulkImportsHistoryApp); + } + + const originalApiVersion = gon.api_version; + beforeAll(() => { + gon.api_version = 'v4'; + }); + + afterAll(() => { + gon.api_version = originalApiVersion; + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('general behavior', () => { + it('renders loading state when loading', () => { + createComponent(); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('renders empty state when no data is available', async () => { + mock.onGet(API_URL).reply(200, [], DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find(GlEmptyState).exists()).toBe(true); + }); + + it('renders table with data when history is available', async () => { + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + + const table = wrapper.find(GlTable); + expect(table.exists()).toBe(true); + // can't use .props() or .attributes() here + expect(table.vm.$attrs.items).toHaveLength(DUMMY_RESPONSE.length); + }); + + it('changes page when requested by pagination bar', async () => { + const NEW_PAGE = 4; + + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + mock.resetHistory(); + + wrapper.findComponent(PaginationBar).vm.$emit('set-page', NEW_PAGE); + await axios.waitForAll(); + + expect(mock.history.get.length).toBe(1); + expect(mock.history.get[0].params).toStrictEqual(expect.objectContaining({ page: NEW_PAGE })); + }); + }); + + it('changes page size when requested by pagination bar', async () => { + const NEW_PAGE_SIZE = 4; + + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + mock.resetHistory(); + + wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE); + await axios.waitForAll(); + + expect(mock.history.get.length).toBe(1); + expect(mock.history.get[0].params).toStrictEqual( + expect.objectContaining({ per_page: NEW_PAGE_SIZE }), + ); + }); + + describe('details button', () => { + beforeEach(() => { + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent({ shallow: false }); + return axios.waitForAll(); + }); + + it('renders details button if relevant item has failures', async () => { + expect( + extendedWrapper(wrapper.find('tbody').findAll('tr').at(1)).findByText('Details').exists(), + ).toBe(true); + }); + + it('does not render details button if relevant item has no failures', () => { + expect( + extendedWrapper(wrapper.find('tbody').findAll('tr').at(0)).findByText('Details').exists(), + ).toBe(false); + }); + + it('expands details when details button is clicked', async () => { + const ORIGINAL_ROW_INDEX = 1; + await extendedWrapper(wrapper.find('tbody').findAll('tr').at(ORIGINAL_ROW_INDEX)) + .findByText('Details') + .trigger('click'); + + const detailsRowContent = wrapper + .find('tbody') + .findAll('tr') + .at(ORIGINAL_ROW_INDEX + 1) + .find('pre'); + + expect(detailsRowContent.exists()).toBe(true); + expect(JSON.parse(detailsRowContent.text())).toStrictEqual(DUMMY_RESPONSE[1].failures); + }); + }); +}); diff --git a/spec/frontend/pages/profiles/password_prompt/password_prompt_modal_spec.js b/spec/frontend/pages/profiles/password_prompt/password_prompt_modal_spec.js new file mode 100644 index 00000000000..b722ac1e97b --- /dev/null +++ b/spec/frontend/pages/profiles/password_prompt/password_prompt_modal_spec.js @@ -0,0 +1,92 @@ +import { GlModal } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + I18N_PASSWORD_PROMPT_CANCEL_BUTTON, + I18N_PASSWORD_PROMPT_CONFIRM_BUTTON, +} from '~/pages/profiles/password_prompt/constants'; +import PasswordPromptModal from '~/pages/profiles/password_prompt/password_prompt_modal.vue'; + +const createComponent = ({ props }) => { + return shallowMountExtended(PasswordPromptModal, { + propsData: { + ...props, + }, + }); +}; + +describe('Password prompt modal', () => { + let wrapper; + + const mockPassword = 'not+fake+shady+password'; + const mockEvent = { preventDefault: jest.fn() }; + const handleConfirmPasswordSpy = jest.fn(); + + const findField = () => wrapper.findByTestId('password-prompt-field'); + const findModal = () => wrapper.findComponent(GlModal); + const findConfirmBtn = () => findModal().props('actionPrimary'); + const findConfirmBtnDisabledState = () => + findModal().props('actionPrimary').attributes[2].disabled; + + const findCancelBtn = () => findModal().props('actionCancel'); + + const submitModal = () => findModal().vm.$emit('primary', mockEvent); + const setPassword = (newPw) => findField().vm.$emit('input', newPw); + + beforeEach(() => { + wrapper = createComponent({ + props: { + handleConfirmPassword: handleConfirmPasswordSpy, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the password field', () => { + expect(findField().exists()).toBe(true); + }); + + it('renders the confirm button', () => { + expect(findConfirmBtn().text).toEqual(I18N_PASSWORD_PROMPT_CONFIRM_BUTTON); + }); + + it('renders the cancel button', () => { + expect(findCancelBtn().text).toEqual(I18N_PASSWORD_PROMPT_CANCEL_BUTTON); + }); + + describe('confirm button', () => { + describe('with a valid password', () => { + it('calls the `handleConfirmPassword` method when clicked', async () => { + setPassword(mockPassword); + submitModal(); + + await wrapper.vm.$nextTick(); + + expect(handleConfirmPasswordSpy).toHaveBeenCalledTimes(1); + expect(handleConfirmPasswordSpy).toHaveBeenCalledWith(mockPassword); + }); + + it('enables the confirm button', async () => { + setPassword(mockPassword); + + expect(findConfirmBtnDisabledState()).toBe(true); + + await wrapper.vm.$nextTick(); + + expect(findConfirmBtnDisabledState()).toBe(false); + }); + }); + + it('without a valid password is disabled', async () => { + setPassword(''); + + expect(findConfirmBtnDisabledState()).toBe(true); + + await wrapper.vm.$nextTick(); + + expect(findConfirmBtnDisabledState()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap index 417567c9f4c..43361bb6f24 100644 --- a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap +++ b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap @@ -12,11 +12,11 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] <gl-dropdown-stub category="primary" clearalltext="Clear all" + clearalltextclass="gl-px-5" headertext="" hideheaderborder="true" highlighteditemstitle="Selected" highlighteditemstitleclass="gl-px-5" - showhighlighteditemstitle="true" size="medium" text="rspec" variant="default" diff --git a/spec/frontend/pages/projects/new/components/new_project_url_select_spec.js b/spec/frontend/pages/projects/new/components/new_project_url_select_spec.js deleted file mode 100644 index 8a7f9229503..00000000000 --- a/spec/frontend/pages/projects/new/components/new_project_url_select_spec.js +++ /dev/null @@ -1,122 +0,0 @@ -import { GlButton, GlDropdown, GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; -import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import NewProjectUrlSelect from '~/pages/projects/new/components/new_project_url_select.vue'; -import searchQuery from '~/pages/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql'; - -describe('NewProjectUrlSelect component', () => { - let wrapper; - - const data = { - currentUser: { - groups: { - nodes: [ - { - id: 'gid://gitlab/Group/26', - fullPath: 'flightjs', - }, - { - id: 'gid://gitlab/Group/28', - fullPath: 'h5bp', - }, - ], - }, - namespace: { - id: 'gid://gitlab/Namespace/1', - fullPath: 'root', - }, - }, - }; - - const localVue = createLocalVue(); - localVue.use(VueApollo); - - const requestHandlers = [[searchQuery, jest.fn().mockResolvedValue({ data })]]; - const apolloProvider = createMockApollo(requestHandlers); - - const provide = { - namespaceFullPath: 'h5bp', - namespaceId: '28', - rootUrl: 'https://gitlab.com/', - trackLabel: 'blank_project', - }; - - const mountComponent = ({ mountFn = shallowMount } = {}) => - mountFn(NewProjectUrlSelect, { localVue, apolloProvider, provide }); - - const findButtonLabel = () => wrapper.findComponent(GlButton); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findHiddenInput = () => wrapper.find('input'); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders the root url as a label', () => { - wrapper = mountComponent(); - - expect(findButtonLabel().text()).toBe(provide.rootUrl); - expect(findButtonLabel().props('label')).toBe(true); - }); - - it('renders a dropdown with the initial namespace full path as the text', () => { - wrapper = mountComponent(); - - expect(findDropdown().props('text')).toBe(provide.namespaceFullPath); - }); - - it('renders a dropdown with the initial namespace id in the hidden input', () => { - wrapper = mountComponent(); - - expect(findHiddenInput().attributes('value')).toBe(provide.namespaceId); - }); - - it('renders expected dropdown items', async () => { - wrapper = mountComponent({ mountFn: mount }); - - jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); - - const listItems = wrapper.findAll('li'); - - expect(listItems.at(0).findComponent(GlDropdownSectionHeader).text()).toBe('Groups'); - expect(listItems.at(1).text()).toBe(data.currentUser.groups.nodes[0].fullPath); - expect(listItems.at(2).text()).toBe(data.currentUser.groups.nodes[1].fullPath); - expect(listItems.at(3).findComponent(GlDropdownSectionHeader).text()).toBe('Users'); - expect(listItems.at(4).text()).toBe(data.currentUser.namespace.fullPath); - }); - - it('updates hidden input with selected namespace', async () => { - wrapper = mountComponent(); - - jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); - - wrapper.findComponent(GlDropdownItem).vm.$emit('click'); - - await wrapper.vm.$nextTick(); - - expect(findHiddenInput().attributes()).toMatchObject({ - name: 'project[namespace_id]', - value: getIdFromGraphQLId(data.currentUser.groups.nodes[0].id).toString(), - }); - }); - - it('tracks clicking on the dropdown', () => { - wrapper = mountComponent(); - - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - - findDropdown().vm.$emit('show'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'activate_form_input', { - label: provide.trackLabel, - property: 'project_path', - }); - - unmockTracking(); - }); -}); 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 2a3b07f95f2..53c1733eab9 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 @@ -10,7 +10,17 @@ describe('Timezone Dropdown', () => { let $dropdownEl = null; let $wrapper = null; const tzListSel = '.dropdown-content ul li a.is-active'; - const tzDropdownToggleText = '.dropdown-toggle-text'; + + const initTimezoneDropdown = (options = {}) => { + // eslint-disable-next-line no-new + new TimezoneDropdown({ + $inputEl, + $dropdownEl, + ...options, + }); + }; + + const findDropdownToggleText = () => $wrapper.find('.dropdown-toggle-text'); describe('Initialize', () => { describe('with dropdown already loaded', () => { @@ -18,16 +28,13 @@ describe('Timezone Dropdown', () => { loadFixtures('pipeline_schedules/edit.html'); $wrapper = $('.dropdown'); $inputEl = $('#schedule_cron_timezone'); + $inputEl.val(''); $dropdownEl = $('.js-timezone-dropdown'); - - // eslint-disable-next-line no-new - new TimezoneDropdown({ - $inputEl, - $dropdownEl, - }); }); it('can take an $inputEl in the constructor', () => { + initTimezoneDropdown(); + const tzStr = '[UTC + 5.5] Sri Jayawardenepura'; const tzValue = 'Asia/Colombo'; @@ -42,6 +49,8 @@ describe('Timezone Dropdown', () => { }); it('will format data array of timezones into a list of offsets', () => { + initTimezoneDropdown(); + const data = $dropdownEl.data('data'); const formatted = $wrapper.find(tzListSel).text(); @@ -50,10 +59,28 @@ describe('Timezone Dropdown', () => { }); }); - it('will default the timezone to UTC', () => { - const tz = $inputEl.val(); + describe('when `allowEmpty` property is `false`', () => { + beforeEach(() => { + initTimezoneDropdown(); + }); + + it('will default the timezone to UTC', () => { + const tz = $inputEl.val(); - expect(tz).toBe('UTC'); + expect(tz).toBe('UTC'); + }); + }); + + describe('when `allowEmpty` property is `true`', () => { + beforeEach(() => { + initTimezoneDropdown({ + allowEmpty: true, + }); + }); + + it('will default the value of the input to an empty string', () => { + expect($inputEl.val()).toBe(''); + }); }); }); @@ -68,23 +95,15 @@ describe('Timezone Dropdown', () => { it('will populate the list of UTC offsets after the dropdown is loaded', () => { expect($wrapper.find(tzListSel).length).toEqual(0); - // eslint-disable-next-line no-new - new TimezoneDropdown({ - $inputEl, - $dropdownEl, - }); + initTimezoneDropdown(); expect($wrapper.find(tzListSel).length).toEqual($($dropdownEl).data('data').length); }); it('will call a provided handler when a new timezone is selected', () => { const onSelectTimezone = jest.fn(); - // eslint-disable-next-line no-new - new TimezoneDropdown({ - $inputEl, - $dropdownEl, - onSelectTimezone, - }); + + initTimezoneDropdown({ onSelectTimezone }); $wrapper.find(tzListSel).first().trigger('click'); @@ -94,24 +113,15 @@ describe('Timezone Dropdown', () => { it('will correctly set the dropdown label if a timezone identifier is set on the inputEl', () => { $inputEl.val('America/St_Johns'); - // eslint-disable-next-line no-new - new TimezoneDropdown({ - $inputEl, - $dropdownEl, - displayFormat: (selectedItem) => formatTimezone(selectedItem), - }); + initTimezoneDropdown({ displayFormat: (selectedItem) => formatTimezone(selectedItem) }); - expect($wrapper.find(tzDropdownToggleText).html()).toEqual('[UTC - 2.5] Newfoundland'); + expect(findDropdownToggleText().html()).toEqual('[UTC - 2.5] Newfoundland'); }); it('will call a provided `displayFormat` handler to format the dropdown value', () => { const displayFormat = jest.fn(); - // eslint-disable-next-line no-new - new TimezoneDropdown({ - $inputEl, - $dropdownEl, - displayFormat, - }); + + initTimezoneDropdown({ displayFormat }); $wrapper.find(tzListSel).first().trigger('click'); 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 e39a3904613..a29db961452 100644 --- a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js +++ b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js @@ -44,7 +44,7 @@ describe('preserve_url_fragment', () => { }); it('when "remember-me" is present', () => { - $('.omniauth-btn') + $('.js-oauth-login') .parent('form') .attr('action', (i, href) => `${href}?remember_me=1`); diff --git a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js index 85222f2ecbb..a43da4b0f19 100644 --- a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js +++ b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js @@ -112,11 +112,6 @@ describe('Pipeline Editor | Text editor component', () => { it('configures editor with syntax highlight', () => { expect(mockUse).toHaveBeenCalledTimes(1); expect(mockRegisterCiSchema).toHaveBeenCalledTimes(1); - expect(mockRegisterCiSchema).toHaveBeenCalledWith({ - projectNamespace: mockProjectNamespace, - projectPath: mockProjectPath, - ref: mockCommitSha, - }); }); }); diff --git a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js index 753682d438b..44656b2b67d 100644 --- a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js +++ b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js @@ -5,22 +5,18 @@ 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 getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import PipelineEditorMiniGraph from '~/pipeline_editor/components/header/pipeline_editor_mini_graph.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 createComponentWithApollo = () => { + const createComponentWithApollo = (glFeatures = {}) => { const handlers = [[getPipelineQuery, mockPipelineQuery]]; mockApollo = createMockApollo(handlers); @@ -30,19 +26,23 @@ describe('Pipeline Status', () => { propsData: { commitSha: mockCommitSha, }, - provide: mockProvide, + provide: { + glFeatures, + projectFullPath: mockProjectFullPath, + }, stubs: { GlLink, GlSprintf }, }); }; const findIcon = () => wrapper.findComponent(GlIcon); - const findCiIcon = () => wrapper.findComponent(CiIcon); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findPipelineEditorMiniGraph = () => wrapper.findComponent(PipelineEditorMiniGraph); 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"]'); const findPipelineViewBtn = () => wrapper.find('[data-testid="pipeline-view-btn"]'); + const findStatusIcon = () => wrapper.find('[data-testid="pipeline-status-icon"]'); beforeEach(() => { mockPipelineQuery = jest.fn(); @@ -50,9 +50,7 @@ describe('Pipeline Status', () => { afterEach(() => { mockPipelineQuery.mockReset(); - wrapper.destroy(); - wrapper = null; }); describe('loading icon', () => { @@ -73,13 +71,13 @@ describe('Pipeline Status', () => { describe('when querying data', () => { describe('when data is set', () => { - beforeEach(async () => { + beforeEach(() => { mockPipelineQuery.mockResolvedValue({ - data: { project: mockProjectPipeline }, + data: { project: mockProjectPipeline() }, }); createComponentWithApollo(); - await waitForPromises(); + waitForPromises(); }); it('query is called with correct variables', async () => { @@ -91,20 +89,24 @@ describe('Pipeline Status', () => { }); it('does not render error', () => { - expect(findIcon().exists()).toBe(false); + expect(findPipelineErrorMsg().exists()).toBe(false); }); it('renders pipeline data', () => { const { id, detailedStatus: { detailsPath }, - } = mockProjectPipeline.pipeline; + } = mockProjectPipeline().pipeline; - expect(findCiIcon().exists()).toBe(true); + expect(findStatusIcon().exists()).toBe(true); expect(findPipelineId().text()).toBe(`#${id.match(/\d+/g)[0]}`); expect(findPipelineCommit().text()).toBe(mockCommitSha); expect(findPipelineViewBtn().attributes('href')).toBe(detailsPath); }); + + it('does not render the pipeline mini graph', () => { + expect(findPipelineEditorMiniGraph().exists()).toBe(false); + }); }); describe('when data cannot be fetched', () => { @@ -121,11 +123,26 @@ describe('Pipeline Status', () => { }); it('does not render pipeline data', () => { - expect(findCiIcon().exists()).toBe(false); + expect(findStatusIcon().exists()).toBe(false); expect(findPipelineId().exists()).toBe(false); expect(findPipelineCommit().exists()).toBe(false); expect(findPipelineViewBtn().exists()).toBe(false); }); }); }); + + describe('when feature flag for pipeline mini graph is enabled', () => { + beforeEach(() => { + mockPipelineQuery.mockResolvedValue({ + data: { project: mockProjectPipeline() }, + }); + + createComponentWithApollo({ pipelineEditorMiniGraph: true }); + waitForPromises(); + }); + + it('renders the pipeline mini graph', () => { + expect(findPipelineEditorMiniGraph().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js b/spec/frontend/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js new file mode 100644 index 00000000000..3d7c3c839da --- /dev/null +++ b/spec/frontend/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js @@ -0,0 +1,42 @@ +import { shallowMount } from '@vue/test-utils'; +import PipelineEditorMiniGraph from '~/pipeline_editor/components/header/pipeline_editor_mini_graph.vue'; +import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; +import { mockProjectPipeline } from '../../mock_data'; + +describe('Pipeline Status', () => { + let wrapper; + + const createComponent = ({ hasStages = true } = {}) => { + wrapper = shallowMount(PipelineEditorMiniGraph, { + propsData: { + pipeline: mockProjectPipeline({ hasStages }).pipeline, + }, + }); + }; + + const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when there are stages', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders pipeline mini graph', () => { + expect(findPipelineMiniGraph().exists()).toBe(true); + }); + }); + + describe('when there are no stages', () => { + beforeEach(() => { + createComponent({ hasStages: false }); + }); + + it('does not render pipeline mini graph', () => { + expect(findPipelineMiniGraph().exists()).toBe(false); + }); + }); +}); 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 index b019bae886c..8e0a73b6e7c 100644 --- 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 @@ -6,9 +6,6 @@ import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_e describe('Pipeline editor empty state', () => { let wrapper; const defaultProvide = { - glFeatures: { - pipelineEditorEmptyStateAction: false, - }, emptyStateIllustrationPath: 'my/svg/path', }; @@ -51,24 +48,6 @@ describe('Pipeline editor empty state', () => { expect(findFileNav().exists()).toBe(true); }); - 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); diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index f2104f25324..0b0ff14486e 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -247,20 +247,47 @@ export const mockEmptySearchBranches = { export const mockBranchPaginationLimit = 10; export const mockTotalBranches = 20; // must be greater than mockBranchPaginationLimit to test pagination -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 mockProjectPipeline = ({ hasStages = true } = {}) => { + const stages = hasStages + ? { + edges: [ + { + node: { + id: 'gid://gitlab/Ci::Stage/605', + name: 'prepare', + status: 'success', + detailedStatus: { + detailsPath: '/root/sample-ci-project/-/pipelines/268#prepare', + group: 'success', + hasDetails: true, + icon: 'status_success', + id: 'success-605-605', + label: 'passed', + text: 'passed', + tooltip: 'passed', + }, + }, + }, + ], + } + : null; + + return { + 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', + }, + stages, }, - }, + }; }; export const mockLintResponse = { diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index 393cad0546b..b6713319e69 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -22,7 +22,6 @@ import { mockCiConfigPath, mockCiConfigQueryResponse, mockBlobContentQueryResponse, - mockBlobContentQueryResponseEmptyCiFile, mockBlobContentQueryResponseNoCiFile, mockCiYml, mockCommitSha, @@ -43,9 +42,6 @@ const MockSourceEditor = { const mockProvide = { ciConfigPath: mockCiConfigPath, defaultBranch: mockDefaultBranch, - glFeatures: { - pipelineEditorEmptyStateAction: false, - }, projectFullPath: mockProjectFullPath, }; @@ -221,37 +217,12 @@ describe('Pipeline editor app component', () => { }); }); - describe('with an empty CI config file', () => { - describe('with empty state feature flag on', () => { - it('does not show the empty screen state', async () => { - mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseEmptyCiFile); - - await createComponentWithApollo({ - provide: { - glFeatures: { - pipelineEditorEmptyStateAction: true, - }, - }, - }); - - expect(findEmptyState().exists()).toBe(false); - expect(findTextEditor().exists()).toBe(true); - }); - }); - }); - - describe('when landing on the empty state with feature flag on', () => { - it('user can click on CTA button and see an empty editor', async () => { + describe('with no CI config setup', () => { + it('user can click on CTA button to get started', async () => { mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseNoCiFile); mockLatestCommitShaQuery.mockResolvedValue(mockEmptyCommitShaResults); - await createComponentWithApollo({ - provide: { - glFeatures: { - pipelineEditorEmptyStateAction: true, - }, - }, - }); + await createComponentWithApollo(); expect(findEmptyState().exists()).toBe(true); expect(findTextEditor().exists()).toBe(false); diff --git a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js index 7aba336b8e8..335049892ec 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js @@ -25,7 +25,6 @@ describe('Pipeline editor home wrapper', () => { }, provide: { glFeatures: { - pipelineEditorDrawer: true, ...glFeatures, }, }, @@ -94,12 +93,4 @@ describe('Pipeline editor home wrapper', () => { expect(findCommitSection().exists()).toBe(true); }); }); - - describe('Pipeline drawer', () => { - it('hides the drawer when the feature flag is off', () => { - createComponent({ glFeatures: { pipelineEditorDrawer: false } }); - - expect(findPipelineEditorDrawer().exists()).toBe(false); - }); - }); }); 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 index 154828aff4b..1cb43c199aa 100644 --- a/spec/frontend/pipelines/components/pipelines_list/pipeline_mini_graph_spec.js +++ b/spec/frontend/pipelines/components/pipelines_list/pipeline_mini_graph_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; +import { pipelines } from 'test_fixtures/pipelines/pipelines.json'; 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', () => { diff --git a/spec/frontend/pipelines/components/pipelines_list/pipieline_stop_modal_spec.js b/spec/frontend/pipelines/components/pipelines_list/pipieline_stop_modal_spec.js new file mode 100644 index 00000000000..249126390f1 --- /dev/null +++ b/spec/frontend/pipelines/components/pipelines_list/pipieline_stop_modal_spec.js @@ -0,0 +1,27 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlSprintf } from '@gitlab/ui'; +import PipelineStopModal from '~/pipelines/components/pipelines_list/pipeline_stop_modal.vue'; +import { mockPipelineHeader } from '../../mock_data'; + +describe('PipelineStopModal', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(PipelineStopModal, { + propsData: { + pipeline: mockPipelineHeader, + }, + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('should render "stop pipeline" warning', () => { + expect(wrapper.text()).toMatch(`You’re about to stop pipeline #${mockPipelineHeader.id}.`); + }); +}); diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js index cbc5d11403e..06f1fa4c827 100644 --- a/spec/frontend/pipelines/graph/job_item_spec.js +++ b/spec/frontend/pipelines/graph/job_item_spec.js @@ -205,4 +205,64 @@ describe('pipeline graph job item', () => { }, ); }); + + describe('job classes', () => { + it('job class is shown', () => { + createWrapper({ + job: mockJob, + cssClassJobName: 'my-class', + }); + + expect(wrapper.find('a').classes()).toContain('my-class'); + + expect(wrapper.find('a').classes()).not.toContain(triggerActiveClass); + }); + + it('job class is shown, along with hover', () => { + createWrapper({ + job: mockJob, + cssClassJobName: 'my-class', + sourceJobHovered: mockJob.name, + }); + + expect(wrapper.find('a').classes()).toContain('my-class'); + expect(wrapper.find('a').classes()).toContain(triggerActiveClass); + }); + + it('multiple job classes are shown', () => { + createWrapper({ + job: mockJob, + cssClassJobName: ['my-class-1', 'my-class-2'], + }); + + expect(wrapper.find('a').classes()).toContain('my-class-1'); + expect(wrapper.find('a').classes()).toContain('my-class-2'); + + expect(wrapper.find('a').classes()).not.toContain(triggerActiveClass); + }); + + it('multiple job classes are shown conditionally', () => { + createWrapper({ + job: mockJob, + cssClassJobName: { 'my-class-1': true, 'my-class-2': true }, + }); + + expect(wrapper.find('a').classes()).toContain('my-class-1'); + expect(wrapper.find('a').classes()).toContain('my-class-2'); + + expect(wrapper.find('a').classes()).not.toContain(triggerActiveClass); + }); + + it('multiple job classes are shown, along with a hover', () => { + createWrapper({ + job: mockJob, + cssClassJobName: ['my-class-1', 'my-class-2'], + sourceJobHovered: mockJob.name, + }); + + expect(wrapper.find('a').classes()).toContain('my-class-1'); + expect(wrapper.find('a').classes()).toContain('my-class-2'); + expect(wrapper.find('a').classes()).toContain(triggerActiveClass); + }); + }); }); diff --git a/spec/frontend/pipelines/pipeline_multi_actions_spec.js b/spec/frontend/pipelines/pipeline_multi_actions_spec.js index a606595b37d..e24d2e51f08 100644 --- a/spec/frontend/pipelines/pipeline_multi_actions_spec.js +++ b/spec/frontend/pipelines/pipeline_multi_actions_spec.js @@ -95,7 +95,7 @@ describe('Pipeline Multi Actions Dropdown', () => { createComponent({ mockData: { artifacts } }); expect(findFirstArtifactItem().attributes('href')).toBe(artifacts[0].path); - expect(findFirstArtifactItem().text()).toBe(`Download ${artifacts[0].name} artifact`); + expect(findFirstArtifactItem().text()).toBe(artifacts[0].name); }); it('should render empty message when no artifacts are found', () => { diff --git a/spec/frontend/pipelines/pipelines_artifacts_spec.js b/spec/frontend/pipelines/pipelines_artifacts_spec.js index 336255768d7..f33c66dedf3 100644 --- a/spec/frontend/pipelines/pipelines_artifacts_spec.js +++ b/spec/frontend/pipelines/pipelines_artifacts_spec.js @@ -87,8 +87,7 @@ describe('Pipelines Artifacts dropdown', () => { createComponent({ mockData: { artifacts } }); expect(findFirstGlDropdownItem().attributes('href')).toBe(artifacts[0].path); - - expect(findFirstGlDropdownItem().text()).toBe(`Download ${artifacts[0].name} artifact`); + expect(findFirstGlDropdownItem().text()).toBe(artifacts[0].name); }); describe('with a failing request', () => { diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index aa30062c987..2875498bb52 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -4,6 +4,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { chunk } from 'lodash'; import { nextTick } from 'vue'; +import mockPipelinesResponse from 'test_fixtures/pipelines/pipelines.json'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -33,7 +34,6 @@ jest.mock('~/experimentation/utils', () => ({ const mockProjectPath = 'twitter/flight'; 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, diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js index 4472a5ae70d..fb019b463b1 100644 --- a/spec/frontend/pipelines/pipelines_table_spec.js +++ b/spec/frontend/pipelines/pipelines_table_spec.js @@ -1,6 +1,7 @@ import '~/commons'; import { GlTable } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import fixture from 'test_fixtures/pipelines/pipelines.json'; 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'; @@ -20,8 +21,6 @@ describe('Pipelines Table', () => { let pipeline; let wrapper; - const jsonFixtureName = 'pipelines/pipelines.json'; - const defaultProps = { pipelines: [], viewType: 'root', @@ -29,7 +28,8 @@ describe('Pipelines Table', () => { }; const createMockPipeline = () => { - const { pipelines } = getJSONFixture(jsonFixtureName); + // Clone fixture as it could be modified by tests + const { pipelines } = JSON.parse(JSON.stringify(fixture)); return pipelines.find((p) => p.user !== null && p.commit !== null); }; diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js index e931ddb8496..84a9f4776b9 100644 --- a/spec/frontend/pipelines/test_reports/stores/actions_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js @@ -1,5 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; -import { getJSONFixture } from 'helpers/fixtures'; +import testReports from 'test_fixtures/pipelines/test_report.json'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; import createFlash from '~/flash'; @@ -13,7 +13,6 @@ describe('Actions TestReports Store', () => { let mock; let state; - const testReports = getJSONFixture('pipelines/test_report.json'); const summary = { total_count: 1 }; const suiteEndpoint = `${TEST_HOST}/tests/suite.json`; diff --git a/spec/frontend/pipelines/test_reports/stores/getters_spec.js b/spec/frontend/pipelines/test_reports/stores/getters_spec.js index f8298fdaba5..70e3a01dbf1 100644 --- a/spec/frontend/pipelines/test_reports/stores/getters_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/getters_spec.js @@ -1,4 +1,4 @@ -import { getJSONFixture } from 'helpers/fixtures'; +import testReports from 'test_fixtures/pipelines/test_report.json'; import * as getters from '~/pipelines/stores/test_reports/getters'; import { iconForTestStatus, @@ -9,8 +9,6 @@ import { describe('Getters TestReports Store', () => { let state; - const testReports = getJSONFixture('pipelines/test_report.json'); - const defaultState = { blobPath: '/test/blob/path', testReports, diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js index 191e9e7391c..f2dbeec6a06 100644 --- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js @@ -1,12 +1,10 @@ -import { getJSONFixture } from 'helpers/fixtures'; +import testReports from 'test_fixtures/pipelines/test_report.json'; import * as types from '~/pipelines/stores/test_reports/mutation_types'; import mutations from '~/pipelines/stores/test_reports/mutations'; describe('Mutations TestReports Store', () => { let mockState; - const testReports = getJSONFixture('pipelines/test_report.json'); - const defaultState = { endpoint: '', testReports: {}, diff --git a/spec/frontend/pipelines/test_reports/test_reports_spec.js b/spec/frontend/pipelines/test_reports/test_reports_spec.js index e44d59ba888..384b7cf6930 100644 --- a/spec/frontend/pipelines/test_reports/test_reports_spec.js +++ b/spec/frontend/pipelines/test_reports/test_reports_spec.js @@ -1,7 +1,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import { getJSONFixture } from 'helpers/fixtures'; +import testReports from 'test_fixtures/pipelines/test_report.json'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import EmptyState from '~/pipelines/components/test_reports/empty_state.vue'; import TestReports from '~/pipelines/components/test_reports/test_reports.vue'; @@ -16,8 +16,6 @@ describe('Test reports app', () => { let wrapper; let store; - const testReports = getJSONFixture('pipelines/test_report.json'); - const loadingSpinner = () => wrapper.findComponent(GlLoadingIcon); const testsDetail = () => wrapper.findByTestId('tests-detail'); const emptyState = () => wrapper.findComponent(EmptyState); diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js index a87145cc557..793bad6b82a 100644 --- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -1,7 +1,7 @@ import { GlButton, GlFriendlyWrap, GlLink, GlPagination } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import { getJSONFixture } from 'helpers/fixtures'; +import testReports from 'test_fixtures/pipelines/test_report.json'; import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue'; import { TestStatus } from '~/pipelines/constants'; import * as getters from '~/pipelines/stores/test_reports/getters'; @@ -17,7 +17,7 @@ describe('Test reports suite table', () => { const { test_suites: [testSuite], - } = getJSONFixture('pipelines/test_report.json'); + } = testReports; testSuite.test_cases = [...testSuite.test_cases, ...skippedTestCases]; const testCases = testSuite.test_cases; diff --git a/spec/frontend/pipelines/test_reports/test_summary_spec.js b/spec/frontend/pipelines/test_reports/test_summary_spec.js index df404d87c99..7eed6671fb9 100644 --- a/spec/frontend/pipelines/test_reports/test_summary_spec.js +++ b/spec/frontend/pipelines/test_reports/test_summary_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import { getJSONFixture } from 'helpers/fixtures'; +import testReports from 'test_fixtures/pipelines/test_report.json'; import Summary from '~/pipelines/components/test_reports/test_summary.vue'; import { formattedTime } from '~/pipelines/stores/test_reports/utils'; @@ -8,7 +8,7 @@ describe('Test reports summary', () => { const { test_suites: [testSuite], - } = getJSONFixture('pipelines/test_report.json'); + } = testReports; const backButton = () => wrapper.find('.js-back-button'); const totalTests = () => wrapper.find('.js-total-tests'); diff --git a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js index 892a3742fea..0813739d72f 100644 --- a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js @@ -1,6 +1,6 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import { getJSONFixture } from 'helpers/fixtures'; +import testReports from 'test_fixtures/pipelines/test_report.json'; import SummaryTable from '~/pipelines/components/test_reports/test_summary_table.vue'; import * as getters from '~/pipelines/stores/test_reports/getters'; @@ -11,8 +11,6 @@ describe('Test reports summary table', () => { let wrapper; let store; - const testReports = getJSONFixture('pipelines/test_report.json'); - const allSuitesRows = () => wrapper.findAll('.js-suite-row'); const noSuitesToShow = () => wrapper.find('.js-no-tests-suites'); diff --git a/spec/frontend/pages/projects/new/components/app_spec.js b/spec/frontend/projects/new/components/app_spec.js index ab8c6d529a8..f6edbab3cca 100644 --- a/spec/frontend/pages/projects/new/components/app_spec.js +++ b/spec/frontend/projects/new/components/app_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import App from '~/pages/projects/new/components/app.vue'; +import App from '~/projects/new/components/app.vue'; import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; describe('Experimental new project creation app', () => { diff --git a/spec/frontend/pages/projects/new/components/new_project_push_tip_popover_spec.js b/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js index d4cf8c78600..31ddbc80ae4 100644 --- a/spec/frontend/pages/projects/new/components/new_project_push_tip_popover_spec.js +++ b/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js @@ -1,6 +1,6 @@ import { GlPopover, GlFormInputGroup } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import NewProjectPushTipPopover from '~/pages/projects/new/components/new_project_push_tip_popover.vue'; +import NewProjectPushTipPopover from '~/projects/new/components/new_project_push_tip_popover.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; describe('New project push tip popover', () => { diff --git a/spec/frontend/projects/new/components/new_project_url_select_spec.js b/spec/frontend/projects/new/components/new_project_url_select_spec.js new file mode 100644 index 00000000000..aa16b71172b --- /dev/null +++ b/spec/frontend/projects/new/components/new_project_url_select_spec.js @@ -0,0 +1,235 @@ +import { + GlButton, + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import eventHub from '~/projects/new/event_hub'; +import NewProjectUrlSelect from '~/projects/new/components/new_project_url_select.vue'; +import searchQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql'; + +describe('NewProjectUrlSelect component', () => { + let wrapper; + + const data = { + currentUser: { + groups: { + nodes: [ + { + id: 'gid://gitlab/Group/26', + fullPath: 'flightjs', + }, + { + id: 'gid://gitlab/Group/28', + fullPath: 'h5bp', + }, + { + id: 'gid://gitlab/Group/30', + fullPath: 'h5bp/subgroup', + }, + ], + }, + namespace: { + id: 'gid://gitlab/Namespace/1', + fullPath: 'root', + }, + }, + }; + + const localVue = createLocalVue(); + localVue.use(VueApollo); + + const defaultProvide = { + namespaceFullPath: 'h5bp', + namespaceId: '28', + rootUrl: 'https://gitlab.com/', + trackLabel: 'blank_project', + userNamespaceFullPath: 'root', + userNamespaceId: '1', + }; + + const mountComponent = ({ + search = '', + queryResponse = data, + provide = defaultProvide, + mountFn = shallowMount, + } = {}) => { + const requestHandlers = [[searchQuery, jest.fn().mockResolvedValue({ data: queryResponse })]]; + const apolloProvider = createMockApollo(requestHandlers); + + return mountFn(NewProjectUrlSelect, { + localVue, + apolloProvider, + provide, + data() { + return { + search, + }; + }, + }); + }; + + const findButtonLabel = () => wrapper.findComponent(GlButton); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findInput = () => wrapper.findComponent(GlSearchBoxByType); + const findHiddenInput = () => wrapper.find('input'); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the root url as a label', () => { + wrapper = mountComponent(); + + expect(findButtonLabel().text()).toBe(defaultProvide.rootUrl); + expect(findButtonLabel().props('label')).toBe(true); + }); + + describe('when namespaceId is provided', () => { + beforeEach(() => { + wrapper = mountComponent(); + }); + + it('renders a dropdown with the given namespace full path as the text', () => { + expect(findDropdown().props('text')).toBe(defaultProvide.namespaceFullPath); + }); + + it('renders a dropdown with the given namespace id in the hidden input', () => { + expect(findHiddenInput().attributes('value')).toBe(defaultProvide.namespaceId); + }); + }); + + describe('when namespaceId is not provided', () => { + const provide = { + ...defaultProvide, + namespaceFullPath: undefined, + namespaceId: undefined, + }; + + beforeEach(() => { + wrapper = mountComponent({ provide }); + }); + + it("renders a dropdown with the user's namespace full path as the text", () => { + expect(findDropdown().props('text')).toBe(defaultProvide.userNamespaceFullPath); + }); + + it("renders a dropdown with the user's namespace id in the hidden input", () => { + expect(findHiddenInput().attributes('value')).toBe(defaultProvide.userNamespaceId); + }); + }); + + it('focuses on the input when the dropdown is opened', async () => { + wrapper = mountComponent({ mountFn: mount }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + const spy = jest.spyOn(findInput().vm, 'focusInput'); + + findDropdown().vm.$emit('shown'); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('renders expected dropdown items', async () => { + wrapper = mountComponent({ mountFn: mount }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + const listItems = wrapper.findAll('li'); + + expect(listItems).toHaveLength(6); + expect(listItems.at(0).findComponent(GlDropdownSectionHeader).text()).toBe('Groups'); + expect(listItems.at(1).text()).toBe(data.currentUser.groups.nodes[0].fullPath); + expect(listItems.at(2).text()).toBe(data.currentUser.groups.nodes[1].fullPath); + expect(listItems.at(3).text()).toBe(data.currentUser.groups.nodes[2].fullPath); + expect(listItems.at(4).findComponent(GlDropdownSectionHeader).text()).toBe('Users'); + expect(listItems.at(5).text()).toBe(data.currentUser.namespace.fullPath); + }); + + describe('when selecting from a group template', () => { + const groupId = getIdFromGraphQLId(data.currentUser.groups.nodes[1].id); + + beforeEach(async () => { + wrapper = mountComponent({ mountFn: mount }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + eventHub.$emit('select-template', groupId); + }); + + it('filters the dropdown items to the selected group and children', async () => { + const listItems = wrapper.findAll('li'); + + expect(listItems).toHaveLength(3); + expect(listItems.at(0).findComponent(GlDropdownSectionHeader).text()).toBe('Groups'); + expect(listItems.at(1).text()).toBe(data.currentUser.groups.nodes[1].fullPath); + expect(listItems.at(2).text()).toBe(data.currentUser.groups.nodes[2].fullPath); + }); + + it('sets the selection to the group', async () => { + expect(findDropdown().props('text')).toBe(data.currentUser.groups.nodes[1].fullPath); + }); + }); + + it('renders `No matches found` when there are no matching dropdown items', async () => { + const queryResponse = { + currentUser: { + groups: { + nodes: [], + }, + namespace: { + id: 'gid://gitlab/Namespace/1', + fullPath: 'root', + }, + }, + }; + + wrapper = mountComponent({ search: 'no matches', queryResponse, mountFn: mount }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('li').text()).toBe('No matches found'); + }); + + it('updates hidden input with selected namespace', async () => { + wrapper = mountComponent(); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + wrapper.findComponent(GlDropdownItem).vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(findHiddenInput().attributes()).toMatchObject({ + name: 'project[namespace_id]', + value: getIdFromGraphQLId(data.currentUser.groups.nodes[0].id).toString(), + }); + }); + + it('tracks clicking on the dropdown', () => { + wrapper = mountComponent(); + + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + findDropdown().vm.$emit('show'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'activate_form_input', { + label: defaultProvide.trackLabel, + property: 'project_path', + }); + + unmockTracking(); + }); +}); diff --git a/spec/frontend/projects/projects_filterable_list_spec.js b/spec/frontend/projects/projects_filterable_list_spec.js index 377d347623a..d4dbf85b5ca 100644 --- a/spec/frontend/projects/projects_filterable_list_spec.js +++ b/spec/frontend/projects/projects_filterable_list_spec.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/no-deprecated import { getJSONFixture, setHTMLFixture } from 'helpers/fixtures'; import ProjectsFilterableList from '~/projects/projects_filterable_list'; @@ -14,6 +15,7 @@ describe('ProjectsFilterableList', () => { </div> <div class="js-projects-list-holder"></div> `); + // eslint-disable-next-line import/no-deprecated getJSONFixture('static/projects.json'); form = document.querySelector('form#project-filter-form'); filter = document.querySelector('.js-projects-list-filter'); diff --git a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js new file mode 100644 index 00000000000..a42891423cd --- /dev/null +++ b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js @@ -0,0 +1,345 @@ +import { + GlSprintf, + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { getUsers, getGroups, getDeployKeys } from '~/projects/settings/api/access_dropdown_api'; +import AccessDropdown, { i18n } from '~/projects/settings/components/access_dropdown.vue'; +import { ACCESS_LEVELS, LEVEL_TYPES } from '~/projects/settings/constants'; + +jest.mock('~/projects/settings/api/access_dropdown_api', () => ({ + getGroups: jest.fn().mockResolvedValue({ + data: [ + { id: 4, name: 'group4' }, + { id: 5, name: 'group5' }, + { id: 6, name: 'group6' }, + ], + }), + getUsers: jest.fn().mockResolvedValue({ + data: [ + { id: 7, name: 'user7' }, + { id: 8, name: 'user8' }, + { id: 9, name: 'user9' }, + ], + }), + getDeployKeys: jest.fn().mockResolvedValue({ + data: [ + { id: 10, title: 'key10', fingerprint: 'abcdefghijklmnop', owner: { name: 'user1' } }, + { id: 11, title: 'key11', fingerprint: 'abcdefghijklmnop', owner: { name: 'user2' } }, + { id: 12, title: 'key12', fingerprint: 'abcdefghijklmnop', owner: { name: 'user3' } }, + ], + }), +})); + +describe('Access Level Dropdown', () => { + let wrapper; + const mockAccessLevelsData = [ + { + id: 1, + text: 'role1', + }, + { + id: 2, + text: 'role2', + }, + { + id: 3, + text: 'role3', + }, + ]; + + const createComponent = ({ + accessLevelsData = mockAccessLevelsData, + accessLevel = ACCESS_LEVELS.PUSH, + hasLicense, + label, + disabled, + preselectedItems, + } = {}) => { + wrapper = shallowMountExtended(AccessDropdown, { + propsData: { + accessLevelsData, + accessLevel, + hasLicense, + label, + disabled, + preselectedItems, + }, + stubs: { + GlSprintf, + GlDropdown, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownToggleLabel = () => findDropdown().props('text'); + const findAllDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem); + const findAllDropdownHeaders = () => findDropdown().findAllComponents(GlDropdownSectionHeader); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + + const findDropdownItemWithText = (items, text) => + items.filter((item) => item.text().includes(text)).at(0); + + describe('data request', () => { + it('should make an api call for users, groups && deployKeys when user has a license', () => { + createComponent(); + expect(getUsers).toHaveBeenCalled(); + expect(getGroups).toHaveBeenCalled(); + expect(getDeployKeys).toHaveBeenCalled(); + }); + + it('should make an api call for deployKeys but not for users or groups when user does not have a license', () => { + createComponent({ hasLicense: false }); + expect(getUsers).not.toHaveBeenCalled(); + expect(getGroups).not.toHaveBeenCalled(); + expect(getDeployKeys).toHaveBeenCalled(); + }); + + it('should make api calls when search query is updated', async () => { + createComponent(); + const query = 'root'; + + findSearchBox().vm.$emit('input', query); + await nextTick(); + expect(getUsers).toHaveBeenCalledWith(query); + expect(getGroups).toHaveBeenCalled(); + expect(getDeployKeys).toHaveBeenCalledWith(query); + }); + }); + + describe('layout', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('renders headers for each section ', () => { + expect(findAllDropdownHeaders()).toHaveLength(4); + }); + + it('renders dropdown item for each access level type', () => { + expect(findAllDropdownItems()).toHaveLength(12); + }); + }); + + describe('toggleLabel', () => { + let dropdownItems = []; + beforeEach(async () => { + createComponent(); + await waitForPromises(); + dropdownItems = findAllDropdownItems(); + }); + + const findItemByNameAndClick = async (name) => { + findDropdownItemWithText(dropdownItems, name).trigger('click'); + await nextTick(); + }; + + it('when no items selected and custom label provided, displays it and has default CSS class', () => { + wrapper.destroy(); + const customLabel = 'Set the access level'; + createComponent({ label: customLabel }); + expect(findDropdownToggleLabel()).toBe(customLabel); + expect(findDropdown().props('toggleClass')).toBe('gl-text-gray-500!'); + }); + + it('when no items selected, displays a default fallback label and has default CSS class ', () => { + expect(findDropdownToggleLabel()).toBe(i18n.selectUsers); + expect(findDropdown().props('toggleClass')).toBe('gl-text-gray-500!'); + }); + + it('displays a number of selected items for each group level', async () => { + dropdownItems.wrappers.forEach((item) => { + item.trigger('click'); + }); + await nextTick(); + expect(findDropdownToggleLabel()).toBe('3 roles, 3 users, 3 deploy keys, 3 groups'); + }); + + it('with only role selected displays the role name and has no class applied', async () => { + await findItemByNameAndClick('role1'); + expect(findDropdownToggleLabel()).toBe('role1'); + expect(findDropdown().props('toggleClass')).toBe(''); + }); + + it('with only groups selected displays the number of selected groups', async () => { + await findItemByNameAndClick('group4'); + await findItemByNameAndClick('group5'); + await findItemByNameAndClick('group6'); + expect(findDropdownToggleLabel()).toBe('3 groups'); + expect(findDropdown().props('toggleClass')).toBe(''); + }); + + it('with only users selected displays the number of selected users', async () => { + await findItemByNameAndClick('user7'); + await findItemByNameAndClick('user8'); + expect(findDropdownToggleLabel()).toBe('2 users'); + expect(findDropdown().props('toggleClass')).toBe(''); + }); + + it('with users and groups selected displays the number of selected users & groups', async () => { + await findItemByNameAndClick('group4'); + await findItemByNameAndClick('group6'); + await findItemByNameAndClick('user7'); + await findItemByNameAndClick('user9'); + expect(findDropdownToggleLabel()).toBe('2 users, 2 groups'); + expect(findDropdown().props('toggleClass')).toBe(''); + }); + + it('with users and deploy keys selected displays the number of selected users & keys', async () => { + await findItemByNameAndClick('user8'); + await findItemByNameAndClick('key10'); + await findItemByNameAndClick('key11'); + expect(findDropdownToggleLabel()).toBe('1 user, 2 deploy keys'); + expect(findDropdown().props('toggleClass')).toBe(''); + }); + }); + + describe('selecting an item', () => { + it('selects the item on click and deselects on the next click ', async () => { + createComponent(); + await waitForPromises(); + + const item = findAllDropdownItems().at(1); + item.trigger('click'); + await nextTick(); + expect(item.props('isChecked')).toBe(true); + item.trigger('click'); + await nextTick(); + expect(item.props('isChecked')).toBe(false); + }); + + it('emits a formatted update on selection ', async () => { + // ids: the items appear in that order in the dropdown + // 1 2 3 - roles + // 4 5 6 - groups + // 7 8 9 - users + // 10 11 12 - deploy_keys + // we set 2 from each group as preselected. Then for the sake of the test deselect one, leave one as-is + // and select a new one from the group. + // Preselected items should have `id` along with `user_id/group_id/access_level/deplo_key_id`. + // Items to be removed from previous selection will have `_deploy` flag set to true + // Newly selected items will have only `user_id/group_id/access_level/deploy_key_id` (depending on their type); + const preselectedItems = [ + { id: 112, type: 'role', access_level: 2 }, + { id: 113, type: 'role', access_level: 3 }, + { id: 115, type: 'group', group_id: 5 }, + { id: 116, type: 'group', group_id: 6 }, + { id: 118, type: 'user', user_id: 8, name: 'user8' }, + { id: 119, type: 'user', user_id: 9, name: 'user9' }, + { id: 121, type: 'deploy_key', deploy_key_id: 11 }, + { id: 122, type: 'deploy_key', deploy_key_id: 12 }, + ]; + + createComponent({ preselectedItems }); + await waitForPromises(); + const spy = jest.spyOn(wrapper.vm, '$emit'); + const dropdownItems = findAllDropdownItems(); + // select new item from each group + findDropdownItemWithText(dropdownItems, 'role1').trigger('click'); + findDropdownItemWithText(dropdownItems, 'group4').trigger('click'); + findDropdownItemWithText(dropdownItems, 'user7').trigger('click'); + findDropdownItemWithText(dropdownItems, 'key10').trigger('click'); + // deselect one item from each group + findDropdownItemWithText(dropdownItems, 'role2').trigger('click'); + findDropdownItemWithText(dropdownItems, 'group5').trigger('click'); + findDropdownItemWithText(dropdownItems, 'user8').trigger('click'); + findDropdownItemWithText(dropdownItems, 'key11').trigger('click'); + + expect(spy).toHaveBeenLastCalledWith('select', [ + { access_level: 1 }, + { id: 112, access_level: 2, _destroy: true }, + { id: 113, access_level: 3 }, + { group_id: 4 }, + { id: 115, group_id: 5, _destroy: true }, + { id: 116, group_id: 6 }, + { user_id: 7 }, + { id: 118, user_id: 8, _destroy: true }, + { id: 119, user_id: 9 }, + { deploy_key_id: 10 }, + { id: 121, deploy_key_id: 11, _destroy: true }, + { id: 122, deploy_key_id: 12 }, + ]); + }); + }); + + describe('Handling preselected items', () => { + const preselectedItems = [ + { id: 112, type: 'role', access_level: 2 }, + { id: 115, type: 'group', group_id: 5 }, + { id: 118, type: 'user', user_id: 8, name: 'user2' }, + { id: 121, type: 'deploy_key', deploy_key_id: 11 }, + ]; + + const findSelected = (type) => + wrapper.findAllByTestId(`${type}-dropdown-item`).filter((w) => w.props('isChecked')); + + beforeEach(async () => { + createComponent({ preselectedItems }); + await waitForPromises(); + }); + + it('should set selected roles as intersection between the server response and preselected', () => { + const selectedRoles = findSelected(LEVEL_TYPES.ROLE); + expect(selectedRoles).toHaveLength(1); + expect(selectedRoles.at(0).text()).toBe('role2'); + }); + + it('should set selected groups as intersection between the server response and preselected', () => { + const selectedGroups = findSelected(LEVEL_TYPES.GROUP); + expect(selectedGroups).toHaveLength(1); + expect(selectedGroups.at(0).text()).toBe('group5'); + }); + + it('should set selected users to all preselected mapping `user_id` to `id`', () => { + const selectedUsers = findSelected(LEVEL_TYPES.USER); + expect(selectedUsers).toHaveLength(1); + expect(selectedUsers.at(0).text()).toBe('user2'); + }); + + it('should set selected deploy keys as intersection between the server response and preselected mapping some keys', () => { + const selectedDeployKeys = findSelected(LEVEL_TYPES.DEPLOY_KEY); + expect(selectedDeployKeys).toHaveLength(1); + expect(selectedDeployKeys.at(0).text()).toContain('key11 (abcdefghijklmn...)'); + }); + }); + + describe('on dropdown open', () => { + beforeEach(() => { + createComponent(); + }); + + it('should set the search input focus', () => { + wrapper.vm.$refs.search.focusInput = jest.fn(); + findDropdown().vm.$emit('shown'); + + expect(wrapper.vm.$refs.search.focusInput).toHaveBeenCalled(); + }); + }); + + describe('on dropdown close', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('should emit `hidden` event with dropdown selection', () => { + jest.spyOn(wrapper.vm, '$emit'); + + findAllDropdownItems().at(1).trigger('click'); + + findDropdown().vm.$emit('hidden'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('hidden', [{ access_level: 2 }]); + }); + }); +}); diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js index a642a8cf8c2..b486992ac4b 100644 --- a/spec/frontend/ref/components/ref_selector_spec.js +++ b/spec/frontend/ref/components/ref_selector_spec.js @@ -4,6 +4,9 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { merge, last } from 'lodash'; import Vuex from 'vuex'; +import commit from 'test_fixtures/api/commits/commit.json'; +import branches from 'test_fixtures/api/branches/branches.json'; +import tags from 'test_fixtures/api/tags/tags.json'; import { trimText } from 'helpers/text_helper'; import { ENTER_KEY } from '~/lib/utils/keys'; import { sprintf } from '~/locale'; @@ -21,11 +24,7 @@ const localVue = createLocalVue(); localVue.use(Vuex); describe('Ref selector component', () => { - const fixtures = { - branches: getJSONFixture('api/branches/branches.json'), - tags: getJSONFixture('api/tags/tags.json'), - commit: getJSONFixture('api/commits/commit.json'), - }; + const fixtures = { branches, tags, commit }; const projectId = '8'; @@ -480,8 +479,6 @@ describe('Ref selector component', () => { it('renders each commit as a selectable item with the short SHA and commit title', () => { const dropdownItems = findCommitDropdownItems(); - const { commit } = fixtures; - expect(dropdownItems.at(0).text()).toBe(`${commit.short_id} ${commit.title}`); }); }); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js index c8fcb3116cd..a5da37a2786 100644 --- a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js @@ -1,13 +1,12 @@ -import { GlFormCheckbox, GlSprintf, GlIcon } from '@gitlab/ui'; +import { GlFormCheckbox, GlSprintf, GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import DeleteButton from '~/registry/explorer/components/delete_button.vue'; + import component from '~/registry/explorer/components/details_page/tags_list_row.vue'; import { REMOVE_TAG_BUTTON_TITLE, - REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, MISSING_MANIFEST_WARNING_TOOLTIP, NOT_AVAILABLE_TEXT, NOT_AVAILABLE_SIZE, @@ -25,19 +24,20 @@ describe('tags list row', () => { const defaultProps = { tag, isMobile: false, index: 0 }; - const findCheckbox = () => wrapper.find(GlFormCheckbox); + const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); const findName = () => wrapper.find('[data-testid="name"]'); const findSize = () => wrapper.find('[data-testid="size"]'); const findTime = () => wrapper.find('[data-testid="time"]'); const findShortRevision = () => wrapper.find('[data-testid="digest"]'); - const findClipboardButton = () => wrapper.find(ClipboardButton); - const findDeleteButton = () => wrapper.find(DeleteButton); - const findTimeAgoTooltip = () => wrapper.find(TimeAgoTooltip); + const findClipboardButton = () => wrapper.findComponent(ClipboardButton); + const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip); const findDetailsRows = () => wrapper.findAll(DetailsRow); const findPublishedDateDetail = () => wrapper.find('[data-testid="published-date-detail"]'); const findManifestDetail = () => wrapper.find('[data-testid="manifest-detail"]'); const findConfigurationDetail = () => wrapper.find('[data-testid="configuration-detail"]'); - const findWarningIcon = () => wrapper.find(GlIcon); + const findWarningIcon = () => wrapper.findComponent(GlIcon); + const findAdditionalActionsMenu = () => wrapper.findComponent(GlDropdown); + const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); const mountComponent = (propsData = defaultProps) => { wrapper = shallowMount(component, { @@ -45,6 +45,7 @@ describe('tags list row', () => { GlSprintf, ListItem, DetailsRow, + GlDropdown, }, propsData, directives: { @@ -262,44 +263,61 @@ describe('tags list row', () => { }); }); - describe('delete button', () => { + describe('additional actions menu', () => { it('exists', () => { mountComponent(); - expect(findDeleteButton().exists()).toBe(true); + expect(findAdditionalActionsMenu().exists()).toBe(true); }); - it('has the correct props/attributes', () => { + it('has the correct props', () => { mountComponent(); - expect(findDeleteButton().attributes()).toMatchObject({ - title: REMOVE_TAG_BUTTON_TITLE, - tooltiptitle: REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, - tooltipdisabled: 'true', + expect(findAdditionalActionsMenu().props()).toMatchObject({ + icon: 'ellipsis_v', + text: 'More actions', + textSrOnly: true, + category: 'tertiary', + right: true, }); }); it.each` - canDelete | digest | disabled - ${true} | ${null} | ${true} - ${false} | ${'foo'} | ${true} - ${false} | ${null} | ${true} - ${true} | ${'foo'} | ${true} + canDelete | digest | disabled | buttonDisabled + ${true} | ${null} | ${true} | ${true} + ${false} | ${'foo'} | ${true} | ${true} + ${false} | ${null} | ${true} | ${true} + ${true} | ${'foo'} | ${true} | ${true} + ${true} | ${'foo'} | ${false} | ${false} `( - 'is disabled when canDelete is $canDelete and digest is $digest and disabled is $disabled', - ({ canDelete, digest, disabled }) => { + 'is $visible that is visible when canDelete is $canDelete and digest is $digest and disabled is $disabled', + ({ canDelete, digest, disabled, buttonDisabled }) => { mountComponent({ ...defaultProps, tag: { ...tag, canDelete, digest }, disabled }); - expect(findDeleteButton().attributes('disabled')).toBe('true'); + expect(findAdditionalActionsMenu().props('disabled')).toBe(buttonDisabled); + expect(findAdditionalActionsMenu().classes('gl-opacity-0')).toBe(buttonDisabled); + expect(findAdditionalActionsMenu().classes('gl-pointer-events-none')).toBe(buttonDisabled); }, ); - it('delete event emits delete', () => { - mountComponent(); + describe('delete button', () => { + it('exists and has the correct attrs', () => { + mountComponent(); + + expect(findDeleteButton().exists()).toBe(true); + expect(findDeleteButton().attributes()).toMatchObject({ + variant: 'danger', + }); + expect(findDeleteButton().text()).toBe(REMOVE_TAG_BUTTON_TITLE); + }); - findDeleteButton().vm.$emit('delete'); + it('delete event emits delete', () => { + mountComponent(); - expect(wrapper.emitted('delete')).toEqual([[]]); + findDeleteButton().vm.$emit('click'); + + expect(wrapper.emitted('delete')).toEqual([[]]); + }); }); }); diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js index b58a53f0af2..e1f24a2b65b 100644 --- a/spec/frontend/registry/explorer/pages/list_spec.js +++ b/spec/frontend/registry/explorer/pages/list_spec.js @@ -129,13 +129,16 @@ describe('List Page', () => { }); }); - describe('connection error', () => { + describe.each([ + { error: 'connectionError', errorName: 'connection error' }, + { error: 'invalidPathError', errorName: 'invalid path error' }, + ])('handling $errorName', ({ error }) => { const config = { - characterError: true, containersErrorImage: 'foo', helpPagePath: 'bar', isGroupPage: false, }; + config[error] = true; it('should show an empty state', () => { mountComponent({ config }); diff --git a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js b/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js index f306fdef624..67f62815720 100644 --- a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js +++ b/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js @@ -1,23 +1,19 @@ import { mount, createLocalVue } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import mockData from 'test_fixtures/issues/related_merge_requests.json'; import axios from '~/lib/utils/axios_utils'; import RelatedMergeRequests from '~/related_merge_requests/components/related_merge_requests.vue'; import createStore from '~/related_merge_requests/store/index'; import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; -const FIXTURE_PATH = 'issues/related_merge_requests.json'; const API_ENDPOINT = '/api/v4/projects/2/issues/33/related_merge_requests'; const localVue = createLocalVue(); describe('RelatedMergeRequests', () => { let wrapper; let mock; - let mockData; beforeEach((done) => { - loadFixtures(FIXTURE_PATH); - mockData = getJSONFixture(FIXTURE_PATH); - // put the fixture in DOM as the component expects document.body.innerHTML = `<div id="js-issuable-app"></div>`; document.getElementById('js-issuable-app').dataset.initial = JSON.stringify(mockData); diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js index 1db6fa21d6b..029d720f7b9 100644 --- a/spec/frontend/releases/components/app_edit_new_spec.js +++ b/spec/frontend/releases/components/app_edit_new_spec.js @@ -3,7 +3,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { merge } from 'lodash'; import Vuex from 'vuex'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import * as commonUtils from '~/lib/utils/common_utils'; @@ -11,7 +11,6 @@ import ReleaseEditNewApp from '~/releases/components/app_edit_new.vue'; import AssetLinksForm from '~/releases/components/asset_links_form.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; -const originalRelease = getJSONFixture('api/releases/release.json'); const originalMilestones = originalRelease.milestones; const releasesPagePath = 'path/to/releases/page'; diff --git a/spec/frontend/releases/components/app_index_apollo_client_spec.js b/spec/frontend/releases/components/app_index_apollo_client_spec.js index 096d319c82f..32bbfd386f5 100644 --- a/spec/frontend/releases/components/app_index_apollo_client_spec.js +++ b/spec/frontend/releases/components/app_index_apollo_client_spec.js @@ -1,6 +1,7 @@ import { cloneDeep } from 'lodash'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import originalAllReleasesQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import allReleasesQuery from 'shared_queries/releases/all_releases.query.graphql'; @@ -32,9 +33,6 @@ jest.mock('~/lib/utils/url_utility', () => ({ })); describe('app_index_apollo_client.vue', () => { - const originalAllReleasesQueryResponse = getJSONFixture( - 'graphql/releases/graphql/queries/all_releases.query.graphql.json', - ); const projectPath = 'project/path'; const newReleasePath = 'path/to/new/release/page'; const before = 'beforeCursor'; diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js index 7ea7a6ffe94..72ebaaaf76c 100644 --- a/spec/frontend/releases/components/app_show_spec.js +++ b/spec/frontend/releases/components/app_show_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { getJSONFixture } from 'helpers/fixtures'; +import oneReleaseQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release.query.graphql.json'; import createMockApollo from 'helpers/mock_apollo_helper'; import createFlash from '~/flash'; import ReleaseShowApp from '~/releases/components/app_show.vue'; @@ -11,10 +11,6 @@ import oneReleaseQuery from '~/releases/graphql/queries/one_release.query.graphq jest.mock('~/flash'); -const oneReleaseQueryResponse = getJSONFixture( - 'graphql/releases/graphql/queries/one_release.query.graphql.json', -); - Vue.use(VueApollo); const EXPECTED_ERROR_MESSAGE = 'Something went wrong while getting the release details.'; diff --git a/spec/frontend/releases/components/asset_links_form_spec.js b/spec/frontend/releases/components/asset_links_form_spec.js index 460007e48ef..839d127e00f 100644 --- a/spec/frontend/releases/components/asset_links_form_spec.js +++ b/spec/frontend/releases/components/asset_links_form_spec.js @@ -1,6 +1,6 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; import * as commonUtils from '~/lib/utils/common_utils'; import { ENTER_KEY } from '~/lib/utils/keys'; import AssetLinksForm from '~/releases/components/asset_links_form.vue'; @@ -9,8 +9,6 @@ import { ASSET_LINK_TYPE, DEFAULT_ASSET_LINK_TYPE } from '~/releases/constants'; const localVue = createLocalVue(); localVue.use(Vuex); -const originalRelease = getJSONFixture('api/releases/release.json'); - describe('Release edit component', () => { let wrapper; let release; diff --git a/spec/frontend/releases/components/evidence_block_spec.js b/spec/frontend/releases/components/evidence_block_spec.js index 50b6d1c4707..973428257b7 100644 --- a/spec/frontend/releases/components/evidence_block_spec.js +++ b/spec/frontend/releases/components/evidence_block_spec.js @@ -1,13 +1,11 @@ import { GlLink, GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { truncateSha } from '~/lib/utils/text_utility'; import EvidenceBlock from '~/releases/components/evidence_block.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -const originalRelease = getJSONFixture('api/releases/release.json'); - describe('Evidence Block', () => { let wrapper; let release; diff --git a/spec/frontend/releases/components/release_block_assets_spec.js b/spec/frontend/releases/components/release_block_assets_spec.js index 3b9b16fa890..c63689e11ac 100644 --- a/spec/frontend/releases/components/release_block_assets_spec.js +++ b/spec/frontend/releases/components/release_block_assets_spec.js @@ -1,13 +1,11 @@ import { GlCollapse } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import { getJSONFixture } from 'helpers/fixtures'; +import { assets } from 'test_fixtures/api/releases/release.json'; import { trimText } from 'helpers/text_helper'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import ReleaseBlockAssets from '~/releases/components/release_block_assets.vue'; import { ASSET_LINK_TYPE } from '~/releases/constants'; -const { assets } = getJSONFixture('api/releases/release.json'); - describe('Release block assets', () => { let wrapper; let defaultProps; diff --git a/spec/frontend/releases/components/release_block_footer_spec.js b/spec/frontend/releases/components/release_block_footer_spec.js index e9fa22b4ec7..f645dc309d7 100644 --- a/spec/frontend/releases/components/release_block_footer_spec.js +++ b/spec/frontend/releases/components/release_block_footer_spec.js @@ -1,13 +1,11 @@ import { GlLink, GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { cloneDeep } from 'lodash'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; import { trimText } from 'helpers/text_helper'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue'; -const originalRelease = getJSONFixture('api/releases/release.json'); - // TODO: Encapsulate date helpers https://gitlab.com/gitlab-org/gitlab/-/issues/320883 const MONTHS_IN_MS = 1000 * 60 * 60 * 24 * 31; const mockFutureDate = new Date(new Date().getTime() + MONTHS_IN_MS).toISOString(); diff --git a/spec/frontend/releases/components/release_block_header_spec.js b/spec/frontend/releases/components/release_block_header_spec.js index 47fd6377fcf..167ae4f32a2 100644 --- a/spec/frontend/releases/components/release_block_header_spec.js +++ b/spec/frontend/releases/components/release_block_header_spec.js @@ -1,14 +1,12 @@ import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { merge } from 'lodash'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; import setWindowLocation from 'helpers/set_window_location_helper'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import ReleaseBlockHeader from '~/releases/components/release_block_header.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; -const originalRelease = getJSONFixture('api/releases/release.json'); - describe('Release block header', () => { let wrapper; let release; diff --git a/spec/frontend/releases/components/release_block_milestone_info_spec.js b/spec/frontend/releases/components/release_block_milestone_info_spec.js index a2bf45c7861..146b2cc7490 100644 --- a/spec/frontend/releases/components/release_block_milestone_info_spec.js +++ b/spec/frontend/releases/components/release_block_milestone_info_spec.js @@ -1,12 +1,12 @@ import { GlProgressBar, GlLink, GlBadge, GlButton } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; import { trimText } from 'helpers/text_helper'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import ReleaseBlockMilestoneInfo from '~/releases/components/release_block_milestone_info.vue'; import { MAX_MILESTONES_TO_DISPLAY } from '~/releases/constants'; -const { milestones: originalMilestones } = getJSONFixture('api/releases/release.json'); +const { milestones: originalMilestones } = originalRelease; describe('Release block milestone info', () => { let wrapper; diff --git a/spec/frontend/releases/components/release_block_spec.js b/spec/frontend/releases/components/release_block_spec.js index 1ca441f7a5a..a847c32b8f1 100644 --- a/spec/frontend/releases/components/release_block_spec.js +++ b/spec/frontend/releases/components/release_block_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; import $ from 'jquery'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; import * as commonUtils from '~/lib/utils/common_utils'; import * as urlUtility from '~/lib/utils/url_utility'; import EvidenceBlock from '~/releases/components/evidence_block.vue'; @@ -9,8 +9,6 @@ import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; import timeagoMixin from '~/vue_shared/mixins/timeago'; -const originalRelease = getJSONFixture('api/releases/release.json'); - describe('Release block', () => { let wrapper; let release; diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index 6504a09df2f..d8329fb82b1 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -1,5 +1,5 @@ import { cloneDeep } from 'lodash'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalOneReleaseForEditingQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json'; import testAction from 'helpers/vuex_action_helper'; import createFlash from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility'; @@ -27,10 +27,6 @@ jest.mock('~/releases/util', () => ({ }, })); -const originalOneReleaseForEditingQueryResponse = getJSONFixture( - 'graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json', -); - describe('Release edit/new actions', () => { let state; let releaseResponse; diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js index 20ae332e500..24dcedb3580 100644 --- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js @@ -1,12 +1,10 @@ -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { ASSET_LINK_TYPE, DEFAULT_ASSET_LINK_TYPE } from '~/releases/constants'; import * as types from '~/releases/stores/modules/edit_new/mutation_types'; import mutations from '~/releases/stores/modules/edit_new/mutations'; import createState from '~/releases/stores/modules/edit_new/state'; -const originalRelease = getJSONFixture('api/releases/release.json'); - describe('Release edit/new mutations', () => { let state; let release; diff --git a/spec/frontend/releases/stores/modules/list/actions_spec.js b/spec/frontend/releases/stores/modules/list/actions_spec.js index af520c2eb20..91406f7e2f4 100644 --- a/spec/frontend/releases/stores/modules/list/actions_spec.js +++ b/spec/frontend/releases/stores/modules/list/actions_spec.js @@ -1,5 +1,5 @@ import { cloneDeep } from 'lodash'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalGraphqlReleasesResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; import testAction from 'helpers/vuex_action_helper'; import { PAGE_SIZE } from '~/releases/constants'; import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; @@ -12,10 +12,6 @@ import * as types from '~/releases/stores/modules/index/mutation_types'; import createState from '~/releases/stores/modules/index/state'; import { gqClient, convertAllReleasesGraphQLResponse } from '~/releases/util'; -const originalGraphqlReleasesResponse = getJSONFixture( - 'graphql/releases/graphql/queries/all_releases.query.graphql.json', -); - describe('Releases State actions', () => { let mockedState; let graphqlReleasesResponse; diff --git a/spec/frontend/releases/stores/modules/list/mutations_spec.js b/spec/frontend/releases/stores/modules/list/mutations_spec.js index 08d803b3c2c..49e324c28a5 100644 --- a/spec/frontend/releases/stores/modules/list/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/list/mutations_spec.js @@ -1,17 +1,13 @@ -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; +import graphqlReleasesResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import * as types from '~/releases/stores/modules/index/mutation_types'; import mutations from '~/releases/stores/modules/index/mutations'; import createState from '~/releases/stores/modules/index/state'; import { convertAllReleasesGraphQLResponse } from '~/releases/util'; -const originalRelease = getJSONFixture('api/releases/release.json'); const originalReleases = [originalRelease]; -const graphqlReleasesResponse = getJSONFixture( - 'graphql/releases/graphql/queries/all_releases.query.graphql.json', -); - describe('Releases Store Mutations', () => { let stateCopy; let pageInfo; diff --git a/spec/frontend/releases/util_spec.js b/spec/frontend/releases/util_spec.js index 36e7be369d3..3c1060cb0e8 100644 --- a/spec/frontend/releases/util_spec.js +++ b/spec/frontend/releases/util_spec.js @@ -1,21 +1,13 @@ import { cloneDeep } from 'lodash'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalAllReleasesQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; +import originalOneReleaseQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release.query.graphql.json'; +import originalOneReleaseForEditingQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json'; import { convertGraphQLRelease, convertAllReleasesGraphQLResponse, convertOneReleaseGraphQLResponse, } from '~/releases/util'; -const originalAllReleasesQueryResponse = getJSONFixture( - 'graphql/releases/graphql/queries/all_releases.query.graphql.json', -); -const originalOneReleaseQueryResponse = getJSONFixture( - 'graphql/releases/graphql/queries/one_release.query.graphql.json', -); -const originalOneReleaseForEditingQueryResponse = getJSONFixture( - 'graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json', -); - describe('releases/util.js', () => { describe('convertGraphQLRelease', () => { let releaseFromResponse; diff --git a/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js b/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js index f99dcbffdff..c548007a8a6 100644 --- a/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js +++ b/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js @@ -38,6 +38,12 @@ describe('code quality issue body issue body', () => { describe('severity rating', () => { it.each` severity | iconClass | iconName + ${'INFO'} | ${'text-primary-400'} | ${'severity-info'} + ${'MINOR'} | ${'text-warning-200'} | ${'severity-low'} + ${'CRITICAL'} | ${'text-danger-600'} | ${'severity-high'} + ${'BLOCKER'} | ${'text-danger-800'} | ${'severity-critical'} + ${'UNKNOWN'} | ${'text-secondary-400'} | ${'severity-unknown'} + ${'INVALID'} | ${'text-secondary-400'} | ${'severity-unknown'} ${'info'} | ${'text-primary-400'} | ${'severity-info'} ${'minor'} | ${'text-warning-200'} | ${'severity-low'} ${'major'} | ${'text-warning-400'} | ${'severity-medium'} diff --git a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js index 84863eac3d3..685a1c50a46 100644 --- a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js +++ b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js @@ -60,7 +60,7 @@ describe('Grouped code quality reports app', () => { }); it('should render loading text', () => { - expect(findWidget().text()).toEqual('Loading codeclimate report'); + expect(findWidget().text()).toEqual('Loading Code quality report'); }); }); @@ -84,7 +84,7 @@ describe('Grouped code quality reports app', () => { }); it('renders summary text', () => { - expect(findWidget().text()).toContain('Code quality degraded on 1 point'); + expect(findWidget().text()).toContain('Code quality degraded'); }); it('renders custom codequality issue body', () => { @@ -99,7 +99,7 @@ describe('Grouped code quality reports app', () => { }); it('renders summary text', () => { - expect(findWidget().text()).toContain('Code quality improved on 1 point'); + expect(findWidget().text()).toContain('Code quality improved'); }); it('renders custom codequality issue body', () => { @@ -115,7 +115,7 @@ describe('Grouped code quality reports app', () => { it('renders summary text', () => { expect(findWidget().text()).toContain( - 'Code quality improved on 1 point and degraded on 1 point', + 'Code quality scanning detected 2 changes in merged results', ); }); @@ -132,7 +132,7 @@ describe('Grouped code quality reports app', () => { }); it('renders error text', () => { - expect(findWidget().text()).toContain('Failed to load codeclimate report'); + expect(findWidget().text()).toContain('Failed to load Code quality report'); }); it('does not render a help icon', () => { diff --git a/spec/frontend/reports/codequality_report/store/getters_spec.js b/spec/frontend/reports/codequality_report/store/getters_spec.js index 0378171084d..b5f6edf85eb 100644 --- a/spec/frontend/reports/codequality_report/store/getters_spec.js +++ b/spec/frontend/reports/codequality_report/store/getters_spec.js @@ -61,9 +61,9 @@ describe('Codequality reports store getters', () => { it.each` resolvedIssues | newIssues | expectedText ${0} | ${0} | ${'No changes to code quality'} - ${0} | ${1} | ${'Code quality degraded on 1 point'} - ${2} | ${0} | ${'Code quality improved on 2 points'} - ${1} | ${2} | ${'Code quality improved on 1 point and degraded on 2 points'} + ${0} | ${1} | ${'Code quality degraded'} + ${2} | ${0} | ${'Code quality improved'} + ${1} | ${2} | ${'Code quality scanning detected 3 changes in merged results'} `( 'returns a summary containing $resolvedIssues resolved issues and $newIssues new issues', ({ newIssues, resolvedIssues, expectedText }) => { diff --git a/spec/frontend/reports/codequality_report/store/utils/codequality_parser_spec.js b/spec/frontend/reports/codequality_report/store/utils/codequality_parser_spec.js index ba95294ab0a..5b77a2c74be 100644 --- a/spec/frontend/reports/codequality_report/store/utils/codequality_parser_spec.js +++ b/spec/frontend/reports/codequality_report/store/utils/codequality_parser_spec.js @@ -25,6 +25,18 @@ describe('Codequality report store utils', () => { }); }); + describe('when an issue has a non-nested path', () => { + const issue = { description: 'Insecure Dependency', path: 'Gemfile.lock' }; + + beforeEach(() => { + [result] = parseCodeclimateMetrics([issue], 'path'); + }); + + it('is parsed', () => { + expect(result.name).toEqual(issue.description); + }); + }); + describe('when an issue has a path but no line', () => { const issue = { description: 'Insecure Dependency', location: { path: 'Gemfile.lock' } }; diff --git a/spec/frontend/reports/components/report_section_spec.js b/spec/frontend/reports/components/report_section_spec.js index e1b36aa1e21..39932b62dbb 100644 --- a/spec/frontend/reports/components/report_section_spec.js +++ b/spec/frontend/reports/components/report_section_spec.js @@ -23,7 +23,7 @@ describe('Report section', () => { const defaultProps = { component: '', status: 'SUCCESS', - loadingText: 'Loading codeclimate report', + loadingText: 'Loading Code Quality report', errorText: 'foo', successText: 'Code quality improved on 1 point and degraded on 1 point', resolvedIssues, @@ -117,13 +117,13 @@ describe('Report section', () => { vm = mountComponent(ReportSection, { component: '', status: 'LOADING', - loadingText: 'Loading codeclimate report', + loadingText: 'Loading Code Quality report', errorText: 'foo', successText: 'Code quality improved on 1 point and degraded on 1 point', hasIssues: false, }); - expect(vm.$el.textContent.trim()).toEqual('Loading codeclimate report'); + expect(vm.$el.textContent.trim()).toEqual('Loading Code Quality report'); }); }); @@ -229,13 +229,13 @@ describe('Report section', () => { vm = mountComponent(ReportSection, { component: '', status: 'ERROR', - loadingText: 'Loading codeclimate report', - errorText: 'Failed to load codeclimate report', + loadingText: 'Loading Code Quality report', + errorText: 'Failed to load Code Quality report', successText: 'Code quality improved on 1 point and degraded on 1 point', hasIssues: false, }); - expect(vm.$el.textContent.trim()).toEqual('Failed to load codeclimate report'); + expect(vm.$el.textContent.trim()).toEqual('Failed to load Code Quality report'); }); }); diff --git a/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js b/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js index 0f7c2559e8b..c60c1f7b63c 100644 --- a/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js +++ b/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js @@ -24,7 +24,7 @@ describe('Grouped test reports app', () => { let wrapper; let mockStore; - const mountComponent = ({ props = { pipelinePath }, glFeatures = {} } = {}) => { + const mountComponent = ({ props = { pipelinePath } } = {}) => { wrapper = mount(GroupedTestReportsApp, { store: mockStore, localVue, @@ -34,9 +34,6 @@ describe('Grouped test reports app', () => { pipelinePath, ...props, }, - provide: { - glFeatures, - }, }); }; @@ -114,8 +111,8 @@ describe('Grouped test reports app', () => { setReports(newFailedTestReports); }); - it('tracks service ping metric when enabled', () => { - mountComponent({ glFeatures: { usageDataITestingSummaryWidgetTotal: true } }); + it('tracks service ping metric', () => { + mountComponent(); findExpandButton().trigger('click'); expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); @@ -123,7 +120,7 @@ describe('Grouped test reports app', () => { }); it('only tracks the first expansion', () => { - mountComponent({ glFeatures: { usageDataITestingSummaryWidgetTotal: true } }); + mountComponent(); const expandButton = findExpandButton(); expandButton.trigger('click'); expandButton.trigger('click'); @@ -131,13 +128,6 @@ describe('Grouped test reports app', () => { expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); }); - - it('does not track service ping metric when disabled', () => { - mountComponent({ glFeatures: { usageDataITestingSummaryWidgetTotal: false } }); - findExpandButton().trigger('click'); - - expect(Api.trackRedisHllUserEvent).not.toHaveBeenCalled(); - }); }); describe('with new failed result', () => { diff --git a/spec/frontend/repository/commits_service_spec.js b/spec/frontend/repository/commits_service_spec.js new file mode 100644 index 00000000000..d924974aede --- /dev/null +++ b/spec/frontend/repository/commits_service_spec.js @@ -0,0 +1,84 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import { loadCommits, isRequested, resetRequestedCommits } from '~/repository/commits_service'; +import httpStatus from '~/lib/utils/http_status'; +import createFlash from '~/flash'; +import { I18N_COMMIT_DATA_FETCH_ERROR } from '~/repository/constants'; + +jest.mock('~/flash'); + +describe('commits service', () => { + let mock; + const url = `${gon.relative_url_root || ''}/my-project/-/refs/main/logs_tree/`; + + beforeEach(() => { + mock = new MockAdapter(axios); + + mock.onGet(url).reply(httpStatus.OK, [], {}); + + jest.spyOn(axios, 'get'); + }); + + afterEach(() => { + mock.restore(); + resetRequestedCommits(); + }); + + const requestCommits = (offset, project = 'my-project', path = '', ref = 'main') => + loadCommits(project, path, ref, offset); + + it('calls axios get', async () => { + const offset = 10; + const project = 'my-project'; + const path = 'my-path'; + const ref = 'my-ref'; + const testUrl = `${gon.relative_url_root || ''}/${project}/-/refs/${ref}/logs_tree/${path}`; + + await requestCommits(offset, project, path, ref); + + expect(axios.get).toHaveBeenCalledWith(testUrl, { params: { format: 'json', offset } }); + }); + + it('encodes the path correctly', async () => { + await requestCommits(1, 'some-project', 'with $peci@l ch@rs/'); + + const encodedUrl = '/some-project/-/refs/main/logs_tree/with%20%24peci%40l%20ch%40rs%2F'; + expect(axios.get).toHaveBeenCalledWith(encodedUrl, expect.anything()); + }); + + it('calls axios get once per batch', async () => { + await Promise.all([requestCommits(0), requestCommits(1), requestCommits(23)]); + + expect(axios.get.mock.calls.length).toEqual(1); + }); + + it('calls axios get twice if an offset is larger than 25', async () => { + await requestCommits(100); + + expect(axios.get.mock.calls[0][1]).toEqual({ params: { format: 'json', offset: 75 } }); + expect(axios.get.mock.calls[1][1]).toEqual({ params: { format: 'json', offset: 100 } }); + }); + + it('updates the list of requested offsets', async () => { + await requestCommits(200); + + expect(isRequested(200)).toBe(true); + }); + + it('resets the list of requested offsets', async () => { + await requestCommits(300); + + resetRequestedCommits(); + expect(isRequested(300)).toBe(false); + }); + + it('calls `createFlash` when the request fails', async () => { + const invalidPath = '/#@ some/path'; + const invalidUrl = `${url}${invalidPath}`; + mock.onGet(invalidUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR, [], {}); + + await requestCommits(1, 'my-project', invalidPath); + + expect(createFlash).toHaveBeenCalledWith({ message: I18N_COMMIT_DATA_FETCH_ERROR }); + }); +}); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index 8331adcdfc2..59db537282b 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -11,13 +11,18 @@ import BlobHeader from '~/blob/components/blob_header.vue'; import BlobButtonGroup from '~/repository/components/blob_button_group.vue'; import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; import BlobEdit from '~/repository/components/blob_edit.vue'; +import ForkSuggestion from '~/repository/components/fork_suggestion.vue'; import { loadViewer, viewerProps } from '~/repository/components/blob_viewers'; import DownloadViewer from '~/repository/components/blob_viewers/download_viewer.vue'; import EmptyViewer from '~/repository/components/blob_viewers/empty_viewer.vue'; import TextViewer from '~/repository/components/blob_viewers/text_viewer.vue'; import blobInfoQuery from '~/repository/queries/blob_info.query.graphql'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { isLoggedIn } from '~/lib/utils/common_utils'; jest.mock('~/repository/components/blob_viewers'); +jest.mock('~/lib/utils/url_utility'); +jest.mock('~/lib/utils/common_utils'); let wrapper; let mockResolver; @@ -34,12 +39,14 @@ const simpleMockData = { webPath: 'some_file.js', editBlobPath: 'some_file.js/edit', ideEditPath: 'some_file.js/ide/edit', + forkAndEditPath: 'some_file.js/fork/edit', + ideForkAndEditPath: 'some_file.js/fork/ide', + canModifyBlob: true, storedExternally: false, rawPath: 'some_file.js', externalStorageUrl: 'some_file.js', replacePath: 'some_file.js/replace', deletePath: 'some_file.js/delete', - forkPath: 'some_file.js/fork', simpleViewer: { fileType: 'text', tooLarge: false, @@ -62,6 +69,8 @@ const projectMockData = { userPermissions: { pushCode: true, downloadCode: true, + createMergeRequestIn: true, + forkProject: true, }, repository: { empty: false, @@ -82,6 +91,8 @@ const createComponentWithApollo = (mockData = {}, inject = {}) => { emptyRepo = defaultEmptyRepo, canPushCode = defaultPushCode, canDownloadCode = defaultDownloadCode, + createMergeRequestIn = projectMockData.userPermissions.createMergeRequestIn, + forkProject = projectMockData.userPermissions.forkProject, pathLocks = [], } = mockData; @@ -89,7 +100,12 @@ const createComponentWithApollo = (mockData = {}, inject = {}) => { data: { project: { id: '1234', - userPermissions: { pushCode: canPushCode, downloadCode: canDownloadCode }, + userPermissions: { + pushCode: canPushCode, + downloadCode: canDownloadCode, + createMergeRequestIn, + forkProject, + }, pathLocks: { nodes: pathLocks, }, @@ -158,9 +174,16 @@ describe('Blob content viewer component', () => { const findBlobEdit = () => wrapper.findComponent(BlobEdit); const findBlobContent = () => wrapper.findComponent(BlobContent); const findBlobButtonGroup = () => wrapper.findComponent(BlobButtonGroup); + const findForkSuggestion = () => wrapper.findComponent(ForkSuggestion); + + beforeEach(() => { + gon.features = { refactorTextViewer: true }; + isLoggedIn.mockReturnValue(true); + }); afterEach(() => { wrapper.destroy(); + mockAxios.reset(); }); it('renders a GlLoadingIcon component', () => { @@ -183,7 +206,6 @@ describe('Blob content viewer component', () => { it('renders a BlobContent component', () => { expect(findBlobContent().props('loading')).toEqual(false); - expect(findBlobContent().props('content')).toEqual('raw content'); expect(findBlobContent().props('isRawContent')).toBe(true); expect(findBlobContent().props('activeViewer')).toEqual({ fileType: 'text', @@ -192,6 +214,16 @@ describe('Blob content viewer component', () => { renderError: null, }); }); + + describe('legacy viewers', () => { + it('loads a legacy viewer when a viewer component is not available', async () => { + createComponentWithApollo({ blobs: { ...simpleMockData, fileType: 'unknown' } }); + await waitForPromises(); + + expect(mockAxios.history.get).toHaveLength(1); + expect(mockAxios.history.get[0].url).toEqual('some_file.js?format=json&viewer=simple'); + }); + }); }); describe('rich viewer', () => { @@ -210,7 +242,6 @@ describe('Blob content viewer component', () => { it('renders a BlobContent component', () => { expect(findBlobContent().props('loading')).toEqual(false); - expect(findBlobContent().props('content')).toEqual('raw content'); expect(findBlobContent().props('isRawContent')).toBe(true); expect(findBlobContent().props('activeViewer')).toEqual({ fileType: 'markup', @@ -241,18 +272,12 @@ describe('Blob content viewer component', () => { }); describe('legacy viewers', () => { - it('does not load a legacy viewer when a rich viewer is not available', async () => { - createComponentWithApollo({ blobs: simpleMockData }); - await waitForPromises(); - - expect(mockAxios.history.get).toHaveLength(0); - }); - - it('loads a legacy viewer when a rich viewer is available', async () => { - createComponentWithApollo({ blobs: richMockData }); + it('loads a legacy viewer when a viewer component is not available', async () => { + createComponentWithApollo({ blobs: { ...richMockData, fileType: 'unknown' } }); await waitForPromises(); expect(mockAxios.history.get).toHaveLength(1); + expect(mockAxios.history.get[0].url).toEqual('some_file.js?format=json&viewer=rich'); }); }); @@ -462,7 +487,7 @@ describe('Blob content viewer component', () => { }); it('does not render if not logged in', async () => { - window.gon.current_user_id = null; + isLoggedIn.mockReturnValueOnce(false); fullFactory({ mockData: { blobInfo: simpleMockData }, @@ -506,4 +531,60 @@ describe('Blob content viewer component', () => { ); }); }); + + describe('edit blob', () => { + beforeEach(() => { + fullFactory({ + mockData: { blobInfo: simpleMockData }, + stubs: { + BlobContent: true, + BlobReplace: true, + }, + }); + }); + + it('simple edit redirects to the simple editor', () => { + findBlobEdit().vm.$emit('edit', 'simple'); + expect(redirectTo).toHaveBeenCalledWith(simpleMockData.editBlobPath); + }); + + it('IDE edit redirects to the IDE editor', () => { + findBlobEdit().vm.$emit('edit', 'ide'); + expect(redirectTo).toHaveBeenCalledWith(simpleMockData.ideEditPath); + }); + + it.each` + loggedIn | canModifyBlob | createMergeRequestIn | forkProject | showForkSuggestion + ${true} | ${false} | ${true} | ${true} | ${true} + ${false} | ${false} | ${true} | ${true} | ${false} + ${true} | ${true} | ${false} | ${true} | ${false} + ${true} | ${true} | ${true} | ${false} | ${false} + `( + 'shows/hides a fork suggestion according to a set of conditions', + async ({ + loggedIn, + canModifyBlob, + createMergeRequestIn, + forkProject, + showForkSuggestion, + }) => { + isLoggedIn.mockReturnValueOnce(loggedIn); + fullFactory({ + mockData: { + blobInfo: { ...simpleMockData, canModifyBlob }, + project: { userPermissions: { createMergeRequestIn, forkProject } }, + }, + stubs: { + BlobContent: true, + BlobButtonGroup: true, + }, + }); + + findBlobEdit().vm.$emit('edit', 'simple'); + await nextTick(); + + expect(findForkSuggestion().exists()).toBe(showForkSuggestion); + }, + ); + }); }); diff --git a/spec/frontend/repository/components/blob_edit_spec.js b/spec/frontend/repository/components/blob_edit_spec.js index 11739674bc9..e2de7bc2957 100644 --- a/spec/frontend/repository/components/blob_edit_spec.js +++ b/spec/frontend/repository/components/blob_edit_spec.js @@ -7,6 +7,7 @@ const DEFAULT_PROPS = { editPath: 'some_file.js/edit', webIdePath: 'some_file.js/ide/edit', showEditButton: true, + needsToFork: false, }; describe('BlobEdit component', () => { @@ -56,7 +57,6 @@ describe('BlobEdit component', () => { it('renders the Edit button', () => { createComponent(); - expect(findEditButton().attributes('href')).toBe(DEFAULT_PROPS.editPath); expect(findEditButton().text()).toBe('Edit'); expect(findEditButton()).not.toBeDisabled(); }); @@ -64,7 +64,6 @@ describe('BlobEdit component', () => { it('renders the Web IDE button', () => { createComponent(); - expect(findWebIdeButton().attributes('href')).toBe(DEFAULT_PROPS.webIdePath); expect(findWebIdeButton().text()).toBe('Web IDE'); expect(findWebIdeButton()).not.toBeDisabled(); }); @@ -72,13 +71,14 @@ describe('BlobEdit component', () => { it('renders WebIdeLink component', () => { createComponent(true); - const { editPath: editUrl, webIdePath: webIdeUrl } = DEFAULT_PROPS; + const { editPath: editUrl, webIdePath: webIdeUrl, needsToFork } = DEFAULT_PROPS; expect(findWebIdeLink().props()).toMatchObject({ editUrl, webIdeUrl, isBlob: true, showEditButton: true, + needsToFork, }); }); diff --git a/spec/frontend/repository/components/blob_viewers/video_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/video_viewer_spec.js new file mode 100644 index 00000000000..34448c03b31 --- /dev/null +++ b/spec/frontend/repository/components/blob_viewers/video_viewer_spec.js @@ -0,0 +1,22 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import VideoViewer from '~/repository/components/blob_viewers/video_viewer.vue'; + +describe('Video Viewer', () => { + let wrapper; + + const propsData = { url: 'some/video.mp4' }; + + const createComponent = () => { + wrapper = shallowMountExtended(VideoViewer, { propsData }); + }; + + const findVideo = () => wrapper.findByTestId('video'); + + it('renders a Video element', () => { + createComponent(); + + expect(findVideo().exists()).toBe(true); + expect(findVideo().attributes('src')).toBe(propsData.url); + expect(findVideo().attributes('controls')).not.toBeUndefined(); + }); +}); diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js index 0733cffe4f4..eb957c635ac 100644 --- a/spec/frontend/repository/components/breadcrumbs_spec.js +++ b/spec/frontend/repository/components/breadcrumbs_spec.js @@ -2,6 +2,7 @@ import { GlDropdown } from '@gitlab/ui'; import { shallowMount, RouterLinkStub } from '@vue/test-utils'; import Breadcrumbs from '~/repository/components/breadcrumbs.vue'; import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; +import NewDirectoryModal from '~/repository/components/new_directory_modal.vue'; const defaultMockRoute = { name: 'blobPath', @@ -10,7 +11,7 @@ const defaultMockRoute = { describe('Repository breadcrumbs component', () => { let wrapper; - const factory = (currentPath, extraProps = {}, mockRoute = {}) => { + const factory = (currentPath, extraProps = {}, mockRoute = {}, newDirModal = true) => { const $apollo = { queries: { userPermissions: { @@ -34,10 +35,12 @@ describe('Repository breadcrumbs component', () => { }, $apollo, }, + provide: { glFeatures: { newDirModal } }, }); }; const findUploadBlobModal = () => wrapper.find(UploadBlobModal); + const findNewDirectoryModal = () => wrapper.find(NewDirectoryModal); afterEach(() => { wrapper.destroy(); @@ -121,4 +124,37 @@ describe('Repository breadcrumbs component', () => { expect(findUploadBlobModal().exists()).toBe(true); }); }); + + describe('renders the new directory modal', () => { + describe('with the feature flag enabled', () => { + beforeEach(() => { + window.gon.features = { + newDirModal: true, + }; + factory('/', { canEditTree: true }); + }); + + it('does not render the modal while loading', () => { + expect(findNewDirectoryModal().exists()).toBe(false); + }); + + it('renders the modal once loaded', async () => { + wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } }); + + await wrapper.vm.$nextTick(); + + expect(findNewDirectoryModal().exists()).toBe(true); + }); + }); + + describe('with the feature flag disabled', () => { + it('does not render the modal', () => { + window.gon.features = { + newDirModal: false, + }; + factory('/', { canEditTree: true }, {}, {}, false); + expect(findNewDirectoryModal().exists()).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/repository/components/fork_suggestion_spec.js b/spec/frontend/repository/components/fork_suggestion_spec.js new file mode 100644 index 00000000000..36a48a3fdb8 --- /dev/null +++ b/spec/frontend/repository/components/fork_suggestion_spec.js @@ -0,0 +1,44 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ForkSuggestion from '~/repository/components/fork_suggestion.vue'; + +const DEFAULT_PROPS = { forkPath: 'some_file.js/fork' }; + +describe('ForkSuggestion component', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(ForkSuggestion, { + propsData: { ...DEFAULT_PROPS }, + }); + }; + + beforeEach(() => createComponent()); + + afterEach(() => wrapper.destroy()); + + const { i18n } = ForkSuggestion; + const findMessage = () => wrapper.findByTestId('message'); + const findForkButton = () => wrapper.findByTestId('fork'); + const findCancelButton = () => wrapper.findByTestId('cancel'); + + it('renders a message', () => { + expect(findMessage().text()).toBe(i18n.message); + }); + + it('renders a Fork button', () => { + const forkButton = findForkButton(); + + expect(forkButton.text()).toBe(i18n.fork); + expect(forkButton.attributes('href')).toBe(DEFAULT_PROPS.forkPath); + }); + + it('renders a Cancel button', () => { + expect(findCancelButton().text()).toBe(i18n.cancel); + }); + + it('emits a cancel event when Cancel button is clicked', () => { + findCancelButton().vm.$emit('click'); + + expect(wrapper.emitted('cancel')).toEqual([[]]); + }); +}); diff --git a/spec/frontend/repository/components/new_directory_modal_spec.js b/spec/frontend/repository/components/new_directory_modal_spec.js new file mode 100644 index 00000000000..fe7f024e3ea --- /dev/null +++ b/spec/frontend/repository/components/new_directory_modal_spec.js @@ -0,0 +1,203 @@ +import { GlModal, GlFormTextarea, GlToggle } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +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 NewDirectoryModal from '~/repository/components/new_directory_modal.vue'; + +jest.mock('~/flash'); +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), +})); + +const initialProps = { + modalTitle: 'Create New Directory', + modalId: 'modal-new-directory', + commitMessage: 'Add new directory', + targetBranch: 'some-target-branch', + originalBranch: 'master', + canPushCode: true, + path: 'create_dir', +}; + +const defaultFormValue = { + dirName: 'foo', + originalBranch: initialProps.originalBranch, + branchName: initialProps.targetBranch, + commitMessage: initialProps.commitMessage, + createNewMr: true, +}; + +describe('NewDirectoryModal', () => { + let wrapper; + let mock; + + const createComponent = (props = {}) => { + wrapper = shallowMount(NewDirectoryModal, { + propsData: { + ...initialProps, + ...props, + }, + attrs: { + static: true, + visible: true, + }, + }); + }; + + const findModal = () => wrapper.findComponent(GlModal); + const findDirName = () => wrapper.find('[name="dir_name"]'); + const findBranchName = () => wrapper.find('[name="branch_name"]'); + const findCommitMessage = () => wrapper.findComponent(GlFormTextarea); + const findMrToggle = () => wrapper.findComponent(GlToggle); + + const fillForm = async (inputValue = {}) => { + const { + dirName = defaultFormValue.dirName, + branchName = defaultFormValue.branchName, + commitMessage = defaultFormValue.commitMessage, + createNewMr = true, + } = inputValue; + + await findDirName().vm.$emit('input', dirName); + await findBranchName().vm.$emit('input', branchName); + await findCommitMessage().vm.$emit('input', commitMessage); + await findMrToggle().vm.$emit('change', createNewMr); + await nextTick; + }; + + const submitForm = async () => { + const mockEvent = { preventDefault: jest.fn() }; + findModal().vm.$emit('primary', mockEvent); + await waitForPromises(); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders modal component', () => { + createComponent(); + + const { modalTitle: title } = initialProps; + + expect(findModal().props()).toMatchObject({ + title, + size: 'md', + actionPrimary: { + text: NewDirectoryModal.i18n.PRIMARY_OPTIONS_TEXT, + }, + actionCancel: { + text: 'Cancel', + }, + }); + }); + + describe('form', () => { + it.each` + component | defaultValue | canPushCode | targetBranch | originalBranch | exist + ${findDirName} | ${undefined} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true} + ${findBranchName} | ${initialProps.targetBranch} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true} + ${findBranchName} | ${undefined} | ${false} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${false} + ${findCommitMessage} | ${initialProps.commitMessage} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true} + ${findMrToggle} | ${'true'} | ${true} | ${'new-target-branch'} | ${'master'} | ${true} + ${findMrToggle} | ${'true'} | ${true} | ${'master'} | ${'master'} | ${true} + `( + 'has the correct form fields ', + ({ component, defaultValue, canPushCode, targetBranch, originalBranch, exist }) => { + createComponent({ + canPushCode, + targetBranch, + originalBranch, + }); + const formField = component(); + + if (!exist) { + expect(formField.exists()).toBe(false); + return; + } + + expect(formField.exists()).toBe(true); + expect(formField.attributes('value')).toBe(defaultValue); + }, + ); + }); + + describe('form submission', () => { + beforeEach(async () => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('valid form', () => { + beforeEach(() => { + createComponent(); + }); + + it('passes the formData', async () => { + const { + dirName, + branchName, + commitMessage, + originalBranch, + createNewMr, + } = defaultFormValue; + mock.onPost(initialProps.path).reply(httpStatusCodes.OK, {}); + await fillForm(); + await submitForm(); + + expect(mock.history.post[0].data.get('dir_name')).toEqual(dirName); + expect(mock.history.post[0].data.get('branch_name')).toEqual(branchName); + expect(mock.history.post[0].data.get('commit_message')).toEqual(commitMessage); + expect(mock.history.post[0].data.get('original_branch')).toEqual(originalBranch); + expect(mock.history.post[0].data.get('create_merge_request')).toEqual(String(createNewMr)); + }); + + it('does not submit "create_merge_request" formData if createNewMr is not checked', async () => { + mock.onPost(initialProps.path).reply(httpStatusCodes.OK, {}); + await fillForm({ createNewMr: false }); + await submitForm(); + expect(mock.history.post[0].data.get('create_merge_request')).toBeNull(); + }); + + it('redirects to the new directory', async () => { + const response = { filePath: 'new-dir-path' }; + mock.onPost(initialProps.path).reply(httpStatusCodes.OK, response); + + await fillForm({ dirName: 'foo', branchName: 'master', commitMessage: 'foo' }); + await submitForm(); + + expect(visitUrl).toHaveBeenCalledWith(response.filePath); + }); + }); + + describe('invalid form', () => { + beforeEach(() => { + createComponent(); + }); + + it('disables submit button', async () => { + await fillForm({ dirName: '', branchName: '', commitMessage: '' }); + expect(findModal().props('actionPrimary').attributes[0].disabled).toBe(true); + }); + + it('creates a flash error', async () => { + mock.onPost(initialProps.path).timeout(); + + await fillForm({ dirName: 'foo', branchName: 'master', commitMessage: 'foo' }); + await submitForm(); + + expect(createFlash).toHaveBeenCalledWith({ + message: NewDirectoryModal.i18n.ERROR_MESSAGE, + }); + }); + }); + }); +}); diff --git a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap index 6f461f4c69b..26064e9b248 100644 --- a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap +++ b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap @@ -31,25 +31,36 @@ exports[`Repository table row component renders a symlink table row 1`] = ` <!----> - <!----> + <gl-icon-stub + class="ml-1" + name="lock" + size="12" + title="Locked by Root" + /> </td> <td class="d-none d-sm-table-cell tree-commit cursor-default" > - <gl-skeleton-loading-stub - class="h-auto" - lines="1" + <gl-link-stub + class="str-truncated-100 tree-commit-link" /> + + <gl-intersection-observer-stub> + <!----> + </gl-intersection-observer-stub> </td> <td class="tree-time-ago text-right cursor-default" > - <gl-skeleton-loading-stub - class="ml-auto h-auto w-50" - lines="1" + <timeago-tooltip-stub + cssclass="" + time="2019-01-01" + tooltipplacement="top" /> + + <!----> </td> </tr> `; @@ -85,25 +96,36 @@ exports[`Repository table row component renders table row 1`] = ` <!----> - <!----> + <gl-icon-stub + class="ml-1" + name="lock" + size="12" + title="Locked by Root" + /> </td> <td class="d-none d-sm-table-cell tree-commit cursor-default" > - <gl-skeleton-loading-stub - class="h-auto" - lines="1" + <gl-link-stub + class="str-truncated-100 tree-commit-link" /> + + <gl-intersection-observer-stub> + <!----> + </gl-intersection-observer-stub> </td> <td class="tree-time-ago text-right cursor-default" > - <gl-skeleton-loading-stub - class="ml-auto h-auto w-50" - lines="1" + <timeago-tooltip-stub + cssclass="" + time="2019-01-01" + tooltipplacement="top" /> + + <!----> </td> </tr> `; @@ -139,25 +161,36 @@ exports[`Repository table row component renders table row for path with special <!----> - <!----> + <gl-icon-stub + class="ml-1" + name="lock" + size="12" + title="Locked by Root" + /> </td> <td class="d-none d-sm-table-cell tree-commit cursor-default" > - <gl-skeleton-loading-stub - class="h-auto" - lines="1" + <gl-link-stub + class="str-truncated-100 tree-commit-link" /> + + <gl-intersection-observer-stub> + <!----> + </gl-intersection-observer-stub> </td> <td class="tree-time-ago text-right cursor-default" > - <gl-skeleton-loading-stub - class="ml-auto h-auto w-50" - lines="1" + <timeago-tooltip-stub + cssclass="" + time="2019-01-01" + tooltipplacement="top" /> + + <!----> </td> </tr> `; diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js index e9e51abaf0f..c8dddefc4f2 100644 --- a/spec/frontend/repository/components/table/index_spec.js +++ b/spec/frontend/repository/components/table/index_spec.js @@ -34,17 +34,45 @@ const MOCK_BLOBS = [ }, ]; -function factory({ path, isLoading = false, hasMore = true, entries = {} }) { +const MOCK_COMMITS = [ + { + fileName: 'blob.md', + type: 'blob', + commit: { + message: 'Updated blob.md', + }, + }, + { + fileName: 'blob2.md', + type: 'blob', + commit: { + message: 'Updated blob2.md', + }, + }, + { + fileName: 'blob3.md', + type: 'blob', + commit: { + message: 'Updated blob3.md', + }, + }, +]; + +function factory({ path, isLoading = false, hasMore = true, entries = {}, commits = [] }) { vm = shallowMount(Table, { propsData: { path, isLoading, entries, hasMore, + commits, }, mocks: { $apollo, }, + provide: { + glFeatures: { lazyLoadCommits: true }, + }, }); } @@ -82,12 +110,15 @@ describe('Repository table component', () => { entries: { blobs: MOCK_BLOBS, }, + commits: MOCK_COMMITS, }); const rows = vm.findAll(TableRow); expect(rows.length).toEqual(3); expect(rows.at(2).attributes().mode).toEqual('120000'); + expect(rows.at(2).props().rowNumber).toBe(2); + expect(rows.at(2).props().commitInfo).toEqual(MOCK_COMMITS[2]); }); describe('Show more button', () => { diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index da28c9873d9..76e9f7da011 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -1,10 +1,12 @@ -import { GlBadge, GlLink, GlIcon } from '@gitlab/ui'; +import { GlBadge, GlLink, GlIcon, GlIntersectionObserver } from '@gitlab/ui'; import { shallowMount, RouterLinkStub } from '@vue/test-utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import TableRow from '~/repository/components/table/row.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import { FILE_SYMLINK_MODE } from '~/vue_shared/constants'; +const COMMIT_MOCK = { lockLabel: 'Locked by Root', committedDate: '2019-01-01' }; + let vm; let $router; @@ -20,12 +22,14 @@ function factory(propsData = {}) { projectPath: 'gitlab-org/gitlab-ce', url: `https://test.com`, totalEntries: 10, + commitInfo: COMMIT_MOCK, + rowNumber: 123, }, directives: { GlHoverLoad: createMockDirective(), }, provide: { - glFeatures: { refactorBlobViewer: true }, + glFeatures: { refactorBlobViewer: true, lazyLoadCommits: true }, }, mocks: { $router, @@ -40,6 +44,7 @@ function factory(propsData = {}) { describe('Repository table row component', () => { const findRouterLink = () => vm.find(RouterLinkStub); + const findIntersectionObserver = () => vm.findComponent(GlIntersectionObserver); afterEach(() => { vm.destroy(); @@ -226,8 +231,6 @@ describe('Repository table row component', () => { currentPath: '/', }); - vm.setData({ commit: { lockLabel: 'Locked by Root', committedDate: '2019-01-01' } }); - return vm.vm.$nextTick().then(() => { expect(vm.find(GlIcon).exists()).toBe(true); expect(vm.find(GlIcon).props('name')).toBe('lock'); @@ -246,4 +249,27 @@ describe('Repository table row component', () => { expect(vm.find(FileIcon).props('loading')).toBe(true); }); + + describe('row visibility', () => { + beforeEach(() => { + factory({ + id: '1', + sha: '1', + path: 'test', + type: 'tree', + currentPath: '/', + }); + }); + it('emits a `row-appear` event', () => { + findIntersectionObserver().vm.$emit('appear'); + expect(vm.emitted('row-appear')).toEqual([ + [ + { + hasCommit: true, + rowNumber: 123, + }, + ], + ]); + }); + }); }); diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index e36287eff29..49397c77215 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -3,6 +3,13 @@ import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.g import FilePreview from '~/repository/components/preview/index.vue'; import FileTable from '~/repository/components/table/index.vue'; import TreeContent from '~/repository/components/tree_content.vue'; +import { loadCommits, isRequested, resetRequestedCommits } from '~/repository/commits_service'; + +jest.mock('~/repository/commits_service', () => ({ + loadCommits: jest.fn(() => Promise.resolve()), + isRequested: jest.fn(), + resetRequestedCommits: jest.fn(), +})); let vm; let $apollo; @@ -23,6 +30,7 @@ function factory(path, data = () => ({})) { glFeatures: { increasePageSizeExponentially: true, paginatedTreeGraphqlQuery: true, + lazyLoadCommits: true, }, }, }); @@ -45,7 +53,7 @@ describe('Repository table component', () => { expect(vm.find(FilePreview).exists()).toBe(true); }); - it('trigger fetchFiles when mounted', async () => { + it('trigger fetchFiles and resetRequestedCommits when mounted', async () => { factory('/'); jest.spyOn(vm.vm, 'fetchFiles').mockImplementation(() => {}); @@ -53,6 +61,7 @@ describe('Repository table component', () => { await vm.vm.$nextTick(); expect(vm.vm.fetchFiles).toHaveBeenCalled(); + expect(resetRequestedCommits).toHaveBeenCalled(); }); describe('normalizeData', () => { @@ -180,4 +189,15 @@ describe('Repository table component', () => { }); }); }); + + it('loads commit data when row-appear event is emitted', () => { + const path = 'some/path'; + const rowNumber = 1; + + factory(path); + findFileTable().vm.$emit('row-appear', { hasCommit: false, rowNumber }); + + expect(isRequested).toHaveBeenCalledWith(rowNumber); + expect(loadCommits).toHaveBeenCalledWith('', path, '', rowNumber); + }); }); diff --git a/spec/frontend/repository/router_spec.js b/spec/frontend/repository/router_spec.js index bb82fa706fd..3f822db601f 100644 --- a/spec/frontend/repository/router_spec.js +++ b/spec/frontend/repository/router_spec.js @@ -24,4 +24,32 @@ describe('Repository router spec', () => { expect(componentsForRoute).toContain(component); } }); + + describe('Storing Web IDE path globally', () => { + const proj = 'foo-bar-group/foo-bar-proj'; + let originalGl; + + beforeEach(() => { + originalGl = window.gl; + }); + + afterEach(() => { + window.gl = originalGl; + }); + + it.each` + path | branch | expectedPath + ${'/'} | ${'main'} | ${`/-/ide/project/${proj}/edit/main/-/`} + ${'/tree/main'} | ${'main'} | ${`/-/ide/project/${proj}/edit/main/-/`} + ${'/tree/feat(test)'} | ${'feat(test)'} | ${`/-/ide/project/${proj}/edit/feat(test)/-/`} + ${'/-/tree/main'} | ${'main'} | ${`/-/ide/project/${proj}/edit/main/-/`} + ${'/-/tree/main/app/assets'} | ${'main'} | ${`/-/ide/project/${proj}/edit/main/-/app/assets/`} + ${'/-/blob/main/file.md'} | ${'main'} | ${`/-/ide/project/${proj}/edit/main/-/file.md`} + `('generates the correct Web IDE url for $path', ({ path, branch, expectedPath } = {}) => { + const router = createRouter(proj, branch); + + router.push(path); + expect(window.gl.webIDEPath).toBe(expectedPath); + }); + }); }); diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index 3292f635f6b..33e9c122080 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -1,3 +1,4 @@ +import { GlLink } from '@gitlab/ui'; import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -5,6 +6,7 @@ import setWindowLocation from 'helpers/set_window_location_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue'; @@ -12,7 +14,6 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_ import RunnerList from '~/runner/components/runner_list.vue'; import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; import RunnerPagination from '~/runner/components/runner_pagination.vue'; -import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; import { ADMIN_FILTERED_SEARCH_NAMESPACE, @@ -49,7 +50,6 @@ describe('AdminRunnersApp', () => { let wrapper; let mockRunnersQuery; - const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp); const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); @@ -86,10 +86,6 @@ describe('AdminRunnersApp', () => { wrapper.destroy(); }); - it('shows the runner type help', () => { - expect(findRunnerTypeHelp().exists()).toBe(true); - }); - it('shows the runner setup instructions', () => { expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); }); @@ -98,6 +94,20 @@ describe('AdminRunnersApp', () => { expect(findRunnerList().props('runners')).toEqual(runnersData.data.runners.nodes); }); + it('runner item links to the runner admin page', async () => { + createComponent({ mountFn: mount }); + + await waitForPromises(); + + const { id, shortSha } = runnersData.data.runners.nodes[0]; + const numericId = getIdFromGraphQLId(id); + + const runnerLink = wrapper.find('tr [data-testid="td-summary"]').find(GlLink); + + expect(runnerLink.text()).toBe(`#${numericId} (${shortSha})`); + expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${numericId}`); + }); + it('requests the runners with no filters', () => { expect(mockRunnersQuery).toHaveBeenLastCalledWith({ status: undefined, diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js index 95f7c38cafc..5aa3879ac3e 100644 --- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -5,15 +5,18 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue'; +import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql'; import { captureException } from '~/runner/sentry_utils'; -import { runnerData } from '../../mock_data'; +import { runnersData, runnerData } from '../../mock_data'; -const mockRunner = runnerData.data.runner; +const mockRunner = runnersData.data.runners.nodes[0]; +const mockRunnerDetails = runnerData.data.runner; const getRunnersQueryName = getRunnersQuery.definitions[0].name.value; +const getGroupRunnersQueryName = getGroupRunnersQuery.definitions[0].name.value; const localVue = createLocalVue(); localVue.use(VueApollo); @@ -36,6 +39,7 @@ describe('RunnerTypeCell', () => { propsData: { runner: { id: mockRunner.id, + adminUrl: mockRunner.adminUrl, active, }, }, @@ -61,7 +65,7 @@ describe('RunnerTypeCell', () => { runnerUpdateMutationHandler.mockResolvedValue({ data: { runnerUpdate: { - runner: runnerData.data.runner, + runner: mockRunnerDetails, errors: [], }, }, @@ -78,7 +82,7 @@ describe('RunnerTypeCell', () => { it('Displays the runner edit link with the correct href', () => { createComponent(); - expect(findEditBtn().attributes('href')).toBe('/admin/runners/1'); + expect(findEditBtn().attributes('href')).toBe(mockRunner.adminUrl); }); describe.each` @@ -231,7 +235,7 @@ describe('RunnerTypeCell', () => { }, }, awaitRefetchQueries: true, - refetchQueries: [getRunnersQueryName], + refetchQueries: [getRunnersQueryName, getGroupRunnersQueryName], }); }); diff --git a/spec/frontend/runner/components/cells/runner_name_cell_spec.js b/spec/frontend/runner/components/cells/runner_summary_cell_spec.js index 26055fc0faf..1c9282e0acd 100644 --- a/spec/frontend/runner/components/cells/runner_name_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_summary_cell_spec.js @@ -1,6 +1,5 @@ -import { GlLink } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import RunnerNameCell from '~/runner/components/cells/runner_name_cell.vue'; +import RunnerSummaryCell from '~/runner/components/cells/runner_summary_cell.vue'; const mockId = '1'; const mockShortSha = '2P6oDVDm'; @@ -9,10 +8,8 @@ const mockDescription = 'runner-1'; describe('RunnerTypeCell', () => { let wrapper; - const findLink = () => wrapper.findComponent(GlLink); - - const createComponent = () => { - wrapper = mount(RunnerNameCell, { + const createComponent = (options) => { + wrapper = mount(RunnerSummaryCell, { propsData: { runner: { id: `gid://gitlab/Ci::Runner/${mockId}`, @@ -20,6 +17,7 @@ describe('RunnerTypeCell', () => { description: mockDescription, }, }, + ...options, }); }; @@ -31,12 +29,23 @@ describe('RunnerTypeCell', () => { wrapper.destroy(); }); - it('Displays the runner link with id and short token', () => { - expect(findLink().text()).toBe(`#${mockId} (${mockShortSha})`); - expect(findLink().attributes('href')).toBe(`/admin/runners/${mockId}`); + it('Displays the runner name as id and short token', () => { + expect(wrapper.text()).toContain(`#${mockId} (${mockShortSha})`); }); it('Displays the runner description', () => { expect(wrapper.text()).toContain(mockDescription); }); + + it('Displays a custom slot', () => { + const slotContent = 'My custom runner summary'; + + createComponent({ + slots: { + 'runner-name': slotContent, + }, + }); + + expect(wrapper.text()).toContain(slotContent); + }); }); diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index 344d1e5c150..e24dffea1eb 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -1,4 +1,4 @@ -import { GlLink, GlTable, GlSkeletonLoader } from '@gitlab/ui'; +import { GlTable, GlSkeletonLoader } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import { cloneDeep } from 'lodash'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -67,11 +67,11 @@ describe('RunnerList', () => { // Badges expect(findCell({ fieldKey: 'type' }).text()).toMatchInterpolatedText('specific paused'); - // Runner identifier - expect(findCell({ fieldKey: 'name' }).text()).toContain( + // Runner summary + expect(findCell({ fieldKey: 'summary' }).text()).toContain( `#${getIdFromGraphQLId(id)} (${shortSha})`, ); - expect(findCell({ fieldKey: 'name' }).text()).toContain(description); + expect(findCell({ fieldKey: 'summary' }).text()).toContain(description); // Other fields expect(findCell({ fieldKey: 'version' }).text()).toBe(version); @@ -136,12 +136,11 @@ describe('RunnerList', () => { }); }); - it('Links to the runner page', () => { - const { id } = mockRunners[0]; + it('Shows runner identifier', () => { + const { id, shortSha } = mockRunners[0]; + const numericId = getIdFromGraphQLId(id); - expect(findCell({ fieldKey: 'name' }).find(GlLink).attributes('href')).toBe( - `/admin/runners/${getIdFromGraphQLId(id)}`, - ); + expect(findCell({ fieldKey: 'summary' }).text()).toContain(`#${numericId} (${shortSha})`); }); describe('When data is loading', () => { diff --git a/spec/frontend/runner/components/runner_state_locked_badge_spec.js b/spec/frontend/runner/components/runner_state_locked_badge_spec.js new file mode 100644 index 00000000000..e92b671f5a1 --- /dev/null +++ b/spec/frontend/runner/components/runner_state_locked_badge_spec.js @@ -0,0 +1,45 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerStateLockedBadge from '~/runner/components/runner_state_locked_badge.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +describe('RunnerTypeBadge', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerStateLockedBadge, { + propsData: { + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders locked state', () => { + expect(wrapper.text()).toBe('locked'); + expect(findBadge().props('variant')).toBe('warning'); + }); + + it('renders tooltip', () => { + expect(getTooltip().value).toBeDefined(); + }); + + it('passes arbitrary attributes to the badge', () => { + createComponent({ props: { size: 'sm' } }); + + expect(findBadge().props('size')).toBe('sm'); + }); +}); diff --git a/spec/frontend/runner/components/runner_state_paused_badge_spec.js b/spec/frontend/runner/components/runner_state_paused_badge_spec.js new file mode 100644 index 00000000000..8df56d6e3f3 --- /dev/null +++ b/spec/frontend/runner/components/runner_state_paused_badge_spec.js @@ -0,0 +1,45 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerStatePausedBadge from '~/runner/components/runner_state_paused_badge.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +describe('RunnerTypeBadge', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerStatePausedBadge, { + propsData: { + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders paused state', () => { + expect(wrapper.text()).toBe('paused'); + expect(findBadge().props('variant')).toBe('danger'); + }); + + it('renders tooltip', () => { + expect(getTooltip().value).toBeDefined(); + }); + + it('passes arbitrary attributes to the badge', () => { + createComponent({ props: { size: 'sm' } }); + + expect(findBadge().props('size')).toBe('sm'); + }); +}); diff --git a/spec/frontend/runner/components/runner_type_badge_spec.js b/spec/frontend/runner/components/runner_type_badge_spec.js index ab5ccf6390f..fb344e65389 100644 --- a/spec/frontend/runner/components/runner_type_badge_spec.js +++ b/spec/frontend/runner/components/runner_type_badge_spec.js @@ -1,18 +1,23 @@ import { GlBadge } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import RunnerTypeBadge from '~/runner/components/runner_type_badge.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; describe('RunnerTypeBadge', () => { let wrapper; const findBadge = () => wrapper.findComponent(GlBadge); + const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); const createComponent = ({ props = {} } = {}) => { wrapper = shallowMount(RunnerTypeBadge, { propsData: { ...props, }, + directives: { + GlTooltip: createMockDirective(), + }, }); }; @@ -20,16 +25,24 @@ describe('RunnerTypeBadge', () => { wrapper.destroy(); }); - it.each` + describe.each` type | text | variant ${INSTANCE_TYPE} | ${'shared'} | ${'success'} ${GROUP_TYPE} | ${'group'} | ${'success'} ${PROJECT_TYPE} | ${'specific'} | ${'info'} - `('displays $type runner with as "$text" with a $variant variant ', ({ type, text, variant }) => { - createComponent({ props: { type } }); + `('displays $type runner', ({ type, text, variant }) => { + beforeEach(() => { + createComponent({ props: { type } }); + }); - expect(findBadge().text()).toBe(text); - expect(findBadge().props('variant')).toBe(variant); + it(`as "${text}" with a ${variant} variant`, () => { + expect(findBadge().text()).toBe(text); + expect(findBadge().props('variant')).toBe(variant); + }); + + it('with a tooltip', () => { + expect(getTooltip().value).toBeDefined(); + }); }); it('validation fails for an incorrect type', () => { diff --git a/spec/frontend/runner/components/runner_type_help_spec.js b/spec/frontend/runner/components/runner_type_help_spec.js deleted file mode 100644 index f0d03282f8e..00000000000 --- a/spec/frontend/runner/components/runner_type_help_spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import { GlBadge } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; - -describe('RunnerTypeHelp', () => { - let wrapper; - - const findBadges = () => wrapper.findAllComponents(GlBadge); - - const createComponent = () => { - wrapper = mount(RunnerTypeHelp); - }; - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('Displays each of the runner types', () => { - expect(findBadges().at(0).text()).toBe('shared'); - expect(findBadges().at(1).text()).toBe('group'); - expect(findBadges().at(2).text()).toBe('specific'); - }); - - it('Displays runner states', () => { - expect(findBadges().at(3).text()).toBe('locked'); - expect(findBadges().at(4).text()).toBe('paused'); - }); -}); diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js index e80da40e3bd..5f3aabd4bc3 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -1,3 +1,4 @@ +import { GlLink } from '@gitlab/ui'; import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -5,13 +6,13 @@ import setWindowLocation from 'helpers/set_window_location_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerList from '~/runner/components/runner_list.vue'; import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; import RunnerPagination from '~/runner/components/runner_pagination.vue'; -import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; import { CREATED_ASC, @@ -34,8 +35,7 @@ localVue.use(VueApollo); const mockGroupFullPath = 'group1'; const mockRegistrationToken = 'AABBCC'; -const mockRunners = groupRunnersData.data.group.runners.nodes; -const mockGroupRunnersLimitedCount = mockRunners.length; +const mockGroupRunnersLimitedCount = groupRunnersData.data.group.runners.edges.length; jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); @@ -48,7 +48,6 @@ describe('GroupRunnersApp', () => { let wrapper; let mockGroupRunnersQuery; - const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp); const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); @@ -82,16 +81,27 @@ describe('GroupRunnersApp', () => { await waitForPromises(); }); - it('shows the runner type help', () => { - expect(findRunnerTypeHelp().exists()).toBe(true); - }); - it('shows the runner setup instructions', () => { expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); }); it('shows the runners list', () => { - expect(findRunnerList().props('runners')).toEqual(groupRunnersData.data.group.runners.nodes); + expect(findRunnerList().props('runners')).toEqual( + groupRunnersData.data.group.runners.edges.map(({ node }) => node), + ); + }); + + it('runner item links to the runner group page', async () => { + const { webUrl, node } = groupRunnersData.data.group.runners.edges[0]; + const { id, shortSha } = node; + + createComponent({ mountFn: mount }); + + await waitForPromises(); + + const runnerLink = wrapper.find('tr [data-testid="td-summary"]').find(GlLink); + expect(runnerLink.text()).toBe(`#${getIdFromGraphQLId(id)} (${shortSha})`); + expect(runnerLink.attributes('href')).toBe(webUrl); }); it('requests the runners with group path and no other filters', () => { diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index c90b9a4c426..b8d0f1273c7 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -1,14 +1,18 @@ -const runnerFixture = (filename) => getJSONFixture(`graphql/runner/${filename}`); - // Fixtures generated by: spec/frontend/fixtures/runner.rb // Admin queries -export const runnersData = runnerFixture('get_runners.query.graphql.json'); -export const runnersDataPaginated = runnerFixture('get_runners.query.graphql.paginated.json'); -export const runnerData = runnerFixture('get_runner.query.graphql.json'); +import runnersData from 'test_fixtures/graphql/runner/get_runners.query.graphql.json'; +import runnersDataPaginated from 'test_fixtures/graphql/runner/get_runners.query.graphql.paginated.json'; +import runnerData from 'test_fixtures/graphql/runner/get_runner.query.graphql.json'; // Group queries -export const groupRunnersData = runnerFixture('get_group_runners.query.graphql.json'); -export const groupRunnersDataPaginated = runnerFixture( - 'get_group_runners.query.graphql.paginated.json', -); +import groupRunnersData from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.json'; +import groupRunnersDataPaginated from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.paginated.json'; + +export { + runnerData, + runnersDataPaginated, + runnersData, + groupRunnersData, + groupRunnersDataPaginated, +}; diff --git a/spec/frontend/search_settings/components/search_settings_spec.js b/spec/frontend/search_settings/components/search_settings_spec.js index 173936e1ce3..6beaea8dba5 100644 --- a/spec/frontend/search_settings/components/search_settings_spec.js +++ b/spec/frontend/search_settings/components/search_settings_spec.js @@ -11,6 +11,7 @@ describe('search_settings/components/search_settings.vue', () => { const GENERAL_SETTINGS_ID = 'js-general-settings'; const ADVANCED_SETTINGS_ID = 'js-advanced-settings'; const EXTRA_SETTINGS_ID = 'js-extra-settings'; + const TEXT_CONTAIN_SEARCH_TERM = `This text contain ${SEARCH_TERM} and <script>alert("111")</script> others.`; let wrapper; @@ -33,6 +34,21 @@ describe('search_settings/components/search_settings.vue', () => { const visibleSectionsCount = () => document.querySelectorAll(`${SECTION_SELECTOR}:not(.${HIDE_CLASS})`).length; const highlightedElementsCount = () => document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).length; + + const highlightedTextNodes = () => { + const highlightedList = Array.from(document.querySelectorAll(`.${HIGHLIGHT_CLASS}`)); + return highlightedList.every((element) => { + return element.textContent.toLowerCase() === SEARCH_TERM.toLowerCase(); + }); + }; + + const matchParentElement = () => { + const highlightedList = Array.from(document.querySelectorAll(`.${HIGHLIGHT_CLASS}`)); + return highlightedList.map((element) => { + return element.parentNode; + }); + }; + const findSearchBox = () => wrapper.find(GlSearchBoxByType); const search = (term) => { findSearchBox().vm.$emit('input', term); @@ -52,6 +68,7 @@ describe('search_settings/components/search_settings.vue', () => { </section> <section id="${EXTRA_SETTINGS_ID}" class="settings"> <span>${SEARCH_TERM}</span> + <span>${TEXT_CONTAIN_SEARCH_TERM}</span> </section> </div> </div> @@ -82,7 +99,23 @@ describe('search_settings/components/search_settings.vue', () => { it('highlight elements that match the search term', () => { search(SEARCH_TERM); - expect(highlightedElementsCount()).toBe(1); + expect(highlightedElementsCount()).toBe(2); + }); + + it('highlight only search term and not the whole line', () => { + search(SEARCH_TERM); + + expect(highlightedTextNodes()).toBe(true); + }); + + it('prevents search xss', () => { + search(SEARCH_TERM); + + const parentNodeList = matchParentElement(); + parentNodeList.forEach((element) => { + const scriptElement = element.getElementsByTagName('script'); + expect(scriptElement.length).toBe(0); + }); }); describe('default', () => { diff --git a/spec/frontend/sidebar/assignees_spec.js b/spec/frontend/sidebar/assignees_spec.js index be27a800418..b3a67f18f82 100644 --- a/spec/frontend/sidebar/assignees_spec.js +++ b/spec/frontend/sidebar/assignees_spec.js @@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; import UsersMockHelper from 'helpers/user_mock_data_helper'; import Assignee from '~/sidebar/components/assignees/assignees.vue'; +import AssigneeAvatarLink from '~/sidebar/components/assignees/assignee_avatar_link.vue'; import UsersMock from './mock_data'; describe('Assignee component', () => { @@ -19,6 +20,7 @@ describe('Assignee component', () => { }); }; + const findAllAvatarLinks = () => wrapper.findAllComponents(AssigneeAvatarLink); const findComponentTextNoUsers = () => wrapper.find('[data-testid="no-value"]'); const findCollapsedChildren = () => wrapper.findAll('.sidebar-collapsed-icon > *'); @@ -148,7 +150,7 @@ describe('Assignee component', () => { editable: true, }); - expect(wrapper.findAll('.user-item').length).toBe(users.length); + expect(findAllAvatarLinks()).toHaveLength(users.length); expect(wrapper.find('.user-list-more').exists()).toBe(false); }); @@ -178,9 +180,9 @@ describe('Assignee component', () => { users, }); - const userItems = wrapper.findAll('.user-list .user-item a'); + const userItems = findAllAvatarLinks(); - expect(userItems.length).toBe(3); + expect(userItems).toHaveLength(3); expect(userItems.at(0).attributes('title')).toBe(users[2].name); }); 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 9f6878db785..6b80224083a 100644 --- a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js +++ b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js @@ -26,9 +26,9 @@ describe('UncollapsedReviewerList component', () => { }); describe('single reviewer', () => { - beforeEach(() => { - const user = userDataMock(); + const user = userDataMock(); + beforeEach(() => { createComponent({ users: [user], }); @@ -39,6 +39,7 @@ describe('UncollapsedReviewerList component', () => { }); it('shows one user with avatar, username and author name', () => { + expect(wrapper.text()).toContain(user.name); expect(wrapper.text()).toContain(`@root`); }); @@ -56,11 +57,18 @@ describe('UncollapsedReviewerList component', () => { }); describe('multiple reviewers', () => { - beforeEach(() => { - const user = userDataMock(); + const user = userDataMock(); + const user2 = { + ...user, + id: 2, + name: 'nonrooty-nonrootersen', + username: 'hello-world', + approved: true, + }; + beforeEach(() => { createComponent({ - users: [user, { ...user, id: 2, username: 'hello-world', approved: true }], + users: [user, user2], }); }); @@ -69,7 +77,9 @@ describe('UncollapsedReviewerList component', () => { }); it('shows both users with avatar, username and author name', () => { + expect(wrapper.text()).toContain(user.name); expect(wrapper.text()).toContain(`@root`); + expect(wrapper.text()).toContain(user2.name); expect(wrapper.text()).toContain(`@hello-world`); }); diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js index 7455f684380..8437ee1b723 100644 --- a/spec/frontend/sidebar/sidebar_labels_spec.js +++ b/spec/frontend/sidebar/sidebar_labels_spec.js @@ -27,6 +27,7 @@ describe('sidebar labels', () => { labelsManagePath: '/gitlab-org/gitlab-test/-/labels', projectIssuesPath: '/gitlab-org/gitlab-test/-/issues', projectPath: 'gitlab-org/gitlab-test', + fullPath: 'gitlab-org/gitlab-test', }; const $apollo = { @@ -110,10 +111,9 @@ describe('sidebar labels', () => { mutation: updateIssueLabelsMutation, variables: { input: { - addLabelIds: [40], iid: defaultProps.iid, projectPath: defaultProps.projectPath, - removeLabelIds: [26, 55], + labelIds: [toLabelGid(29), toLabelGid(28), toLabelGid(27), toLabelGid(40)], }, }, }; diff --git a/spec/frontend/sidebar/todo_spec.js b/spec/frontend/sidebar/todo_spec.js index ff6da3abad0..6829e688c65 100644 --- a/spec/frontend/sidebar/todo_spec.js +++ b/spec/frontend/sidebar/todo_spec.js @@ -27,7 +27,7 @@ describe('SidebarTodo', () => { it.each` state | classes ${false} | ${['gl-button', 'btn', 'btn-default', 'btn-todo', 'issuable-header-btn', 'float-right']} - ${true} | ${['btn-blank', 'btn-todo', 'sidebar-collapsed-icon', 'dont-change-state']} + ${true} | ${['btn-blank', 'btn-todo', 'sidebar-collapsed-icon', 'js-dont-change-state']} `('returns todo button classes for when `collapsed` prop is `$state`', ({ state, classes }) => { createComponent({ collapsed: state }); expect(wrapper.find('button').classes()).toStrictEqual(classes); diff --git a/spec/frontend/snippets/components/show_spec.js b/spec/frontend/snippets/components/show_spec.js index b7b638b5137..af61f4ea54f 100644 --- a/spec/frontend/snippets/components/show_spec.js +++ b/spec/frontend/snippets/components/show_spec.js @@ -41,19 +41,23 @@ describe('Snippet view app', () => { }, }); } + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findEmbedDropdown = () => wrapper.findComponent(EmbedDropdown); + afterEach(() => { wrapper.destroy(); }); it('renders loader while the query is in flight', () => { createComponent({ loading: true }); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(findLoadingIcon().exists()).toBe(true); }); - it('renders all simple components after the query is finished', () => { + it('renders all simple components required after the query is finished', () => { createComponent(); - expect(wrapper.find(SnippetHeader).exists()).toBe(true); - expect(wrapper.find(SnippetTitle).exists()).toBe(true); + expect(wrapper.findComponent(SnippetHeader).exists()).toBe(true); + expect(wrapper.findComponent(SnippetTitle).exists()).toBe(true); }); it('renders embed dropdown component if visibility allows', () => { @@ -65,7 +69,7 @@ describe('Snippet view app', () => { }, }, }); - expect(wrapper.find(EmbedDropdown).exists()).toBe(true); + expect(findEmbedDropdown().exists()).toBe(true); }); it('renders correct snippet-blob components', () => { @@ -98,7 +102,7 @@ describe('Snippet view app', () => { }, }, }); - expect(wrapper.find(EmbedDropdown).exists()).toBe(isRendered); + expect(findEmbedDropdown().exists()).toBe(isRendered); }); }); @@ -120,7 +124,7 @@ describe('Snippet view app', () => { }, }, }); - expect(wrapper.find(CloneDropdownButton).exists()).toBe(isRendered); + expect(wrapper.findComponent(CloneDropdownButton).exists()).toBe(isRendered); }, ); }); diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index fb95be3a77c..552a1c6fcde 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -1,23 +1,30 @@ import { GlButton, GlModal, GlDropdown } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { ApolloMutation } from 'vue-apollo'; +import MockAdapter from 'axios-mock-adapter'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { Blob, BinaryBlob } from 'jest/blob/components/mock_data'; import { differenceInMilliseconds } from '~/lib/utils/datetime_utility'; -import SnippetHeader from '~/snippets/components/snippet_header.vue'; +import SnippetHeader, { i18n } from '~/snippets/components/snippet_header.vue'; import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.graphql'; +import axios from '~/lib/utils/axios_utils'; +import createFlash, { FLASH_TYPES } from '~/flash'; + +jest.mock('~/flash'); describe('Snippet header component', () => { let wrapper; let snippet; let mutationTypes; let mutationVariables; + let mock; let errorMsg; let err; const originalRelativeUrlRoot = gon.relative_url_root; const reportAbusePath = '/-/snippets/42/mark_as_spam'; + const canReportSpam = true; const GlEmoji = { template: '<img/>' }; @@ -47,6 +54,7 @@ describe('Snippet header component', () => { mocks: { $apollo }, provide: { reportAbusePath, + canReportSpam, ...provide, }, propsData: { @@ -118,10 +126,13 @@ describe('Snippet header component', () => { RESOLVE: jest.fn(() => Promise.resolve({ data: { destroySnippet: { errors: [] } } })), REJECT: jest.fn(() => Promise.reject(err)), }; + + mock = new MockAdapter(axios); }); afterEach(() => { wrapper.destroy(); + mock.restore(); gon.relative_url_root = originalRelativeUrlRoot; }); @@ -186,7 +197,6 @@ describe('Snippet header component', () => { { category: 'primary', disabled: false, - href: reportAbusePath, text: 'Submit as spam', variant: 'default', }, @@ -205,7 +215,6 @@ describe('Snippet header component', () => { text: 'Delete', }, { - href: reportAbusePath, text: 'Submit as spam', title: 'Submit as spam', }, @@ -249,6 +258,31 @@ describe('Snippet header component', () => { ); }); + describe('submit snippet as spam', () => { + beforeEach(async () => { + createComponent(); + }); + + it.each` + request | variant | text + ${200} | ${'SUCCESS'} | ${i18n.snippetSpamSuccess} + ${500} | ${'DANGER'} | ${i18n.snippetSpamFailure} + `( + 'renders a "$variant" flash message with "$text" message for a request with a "$request" response', + async ({ request, variant, text }) => { + const submitAsSpamBtn = findButtons().at(2); + mock.onPost(reportAbusePath).reply(request); + submitAsSpamBtn.trigger('click'); + await waitForPromises(); + + expect(createFlash).toHaveBeenLastCalledWith({ + message: expect.stringContaining(text), + type: FLASH_TYPES[variant], + }); + }, + ); + }); + describe('with guest user', () => { beforeEach(() => { createComponent({ @@ -258,6 +292,7 @@ describe('Snippet header component', () => { }, provide: { reportAbusePath: null, + canReportSpam: false, }, }); }); diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js index 4d1b0f54e42..2c8e0fff848 100644 --- a/spec/frontend/test_setup.js +++ b/spec/frontend/test_setup.js @@ -6,7 +6,7 @@ import { setGlobalDateToFakeDate } from 'helpers/fake_date'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import Translate from '~/vue_shared/translate'; -import { getJSONFixture, loadHTMLFixture, setHTMLFixture } from './__helpers__/fixtures'; +import { loadHTMLFixture, setHTMLFixture } from './__helpers__/fixtures'; import { initializeTestTimeout } from './__helpers__/timeout'; import customMatchers from './matchers'; import { setupManualMocks } from './mocks/mocks_helper'; @@ -43,7 +43,6 @@ Vue.use(Translate); // convenience wrapper for migration from Karma Object.assign(global, { - getJSONFixture, loadFixtures: loadHTMLFixture, setFixtures: setHTMLFixture, }); diff --git a/spec/frontend/tracking/get_standard_context_spec.js b/spec/frontend/tracking/get_standard_context_spec.js index b7bdc56b801..ada914b586c 100644 --- a/spec/frontend/tracking/get_standard_context_spec.js +++ b/spec/frontend/tracking/get_standard_context_spec.js @@ -1,5 +1,13 @@ -import { SNOWPLOW_JS_SOURCE } from '~/tracking/constants'; +import { SNOWPLOW_JS_SOURCE, GOOGLE_ANALYTICS_ID_COOKIE_NAME } from '~/tracking/constants'; import getStandardContext from '~/tracking/get_standard_context'; +import { setCookie, removeCookie } from '~/lib/utils/common_utils'; + +const TEST_GA_ID = 'GA1.2.345678901.234567891'; +const TEST_BASE_DATA = { + source: SNOWPLOW_JS_SOURCE, + google_analytics_id: '', + extra: {}, +}; describe('~/tracking/get_standard_context', () => { beforeEach(() => { @@ -10,10 +18,7 @@ describe('~/tracking/get_standard_context', () => { it('returns default object if called without server context', () => { expect(getStandardContext()).toStrictEqual({ schema: undefined, - data: { - source: SNOWPLOW_JS_SOURCE, - extra: {}, - }, + data: TEST_BASE_DATA, }); }); @@ -28,9 +33,8 @@ describe('~/tracking/get_standard_context', () => { expect(getStandardContext()).toStrictEqual({ schema: 'iglu:com.gitlab/gitlab_standard', data: { + ...TEST_BASE_DATA, environment: 'testing', - source: SNOWPLOW_JS_SOURCE, - extra: {}, }, }); }); @@ -50,4 +54,15 @@ describe('~/tracking/get_standard_context', () => { expect(getStandardContext({ extra }).data.extra).toBe(extra); }); + + describe('with Google Analytics cookie present', () => { + afterEach(() => { + removeCookie(GOOGLE_ANALYTICS_ID_COOKIE_NAME); + }); + + it('appends Google Analytics ID', () => { + setCookie(GOOGLE_ANALYTICS_ID_COOKIE_NAME, TEST_GA_ID); + expect(getStandardContext().data.google_analytics_id).toBe(TEST_GA_ID); + }); + }); }); diff --git a/spec/frontend/tracking/tracking_initialization_spec.js b/spec/frontend/tracking/tracking_initialization_spec.js new file mode 100644 index 00000000000..2b70aacc4cb --- /dev/null +++ b/spec/frontend/tracking/tracking_initialization_spec.js @@ -0,0 +1,140 @@ +import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; +import { getExperimentData, getAllExperimentContexts } from '~/experimentation/utils'; +import Tracking, { initUserTracking, initDefaultTrackers } from '~/tracking'; +import getStandardContext from '~/tracking/get_standard_context'; + +jest.mock('~/experimentation/utils', () => ({ + getExperimentData: jest.fn(), + getAllExperimentContexts: jest.fn(), +})); + +describe('Tracking', () => { + let standardContext; + let snowplowSpy; + let bindDocumentSpy; + let trackLoadEventsSpy; + let enableFormTracking; + let setAnonymousUrlsSpy; + + beforeAll(() => { + window.gl = window.gl || {}; + window.gl.snowplowStandardContext = { + schema: 'iglu:com.gitlab/gitlab_standard', + data: { + environment: 'testing', + source: 'unknown', + extra: {}, + }, + }; + + standardContext = getStandardContext(); + }); + + beforeEach(() => { + getExperimentData.mockReturnValue(undefined); + getAllExperimentContexts.mockReturnValue([]); + + window.snowplow = window.snowplow || (() => {}); + window.snowplowOptions = { + namespace: 'gl_test', + hostname: 'app.test.com', + cookieDomain: '.test.com', + }; + + snowplowSpy = jest.spyOn(window, 'snowplow'); + }); + + describe('initUserTracking', () => { + it('calls through to get a new tracker with the expected options', () => { + initUserTracking(); + expect(snowplowSpy).toHaveBeenCalledWith('newTracker', 'gl_test', 'app.test.com', { + namespace: 'gl_test', + hostname: 'app.test.com', + cookieDomain: '.test.com', + appId: '', + userFingerprint: false, + respectDoNotTrack: true, + forceSecureTracker: true, + eventMethod: 'post', + contexts: { webPage: true, performanceTiming: true }, + formTracking: false, + linkClickTracking: false, + pageUnloadTimer: 10, + formTrackingConfig: { + fields: { allow: [] }, + forms: { allow: [] }, + }, + }); + }); + }); + + describe('initDefaultTrackers', () => { + beforeEach(() => { + bindDocumentSpy = jest.spyOn(Tracking, 'bindDocument').mockImplementation(() => null); + trackLoadEventsSpy = jest.spyOn(Tracking, 'trackLoadEvents').mockImplementation(() => null); + enableFormTracking = jest + .spyOn(Tracking, 'enableFormTracking') + .mockImplementation(() => null); + setAnonymousUrlsSpy = jest.spyOn(Tracking, 'setAnonymousUrls').mockImplementation(() => null); + }); + + it('should activate features based on what has been enabled', () => { + initDefaultTrackers(); + expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', 30, 30); + expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [standardContext]); + expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking'); + expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking'); + + window.snowplowOptions = { + ...window.snowplowOptions, + formTracking: true, + linkClickTracking: true, + formTrackingConfig: { forms: { whitelist: ['foo'] }, fields: { whitelist: ['bar'] } }, + }; + + initDefaultTrackers(); + expect(enableFormTracking).toHaveBeenCalledWith(window.snowplowOptions.formTrackingConfig); + expect(snowplowSpy).toHaveBeenCalledWith('enableLinkClickTracking'); + }); + + it('binds the document event handling', () => { + initDefaultTrackers(); + expect(bindDocumentSpy).toHaveBeenCalled(); + }); + + it('tracks page loaded events', () => { + initDefaultTrackers(); + expect(trackLoadEventsSpy).toHaveBeenCalled(); + }); + + it('calls the anonymized URLs method', () => { + initDefaultTrackers(); + expect(setAnonymousUrlsSpy).toHaveBeenCalled(); + }); + + describe('when there are experiment contexts', () => { + const experimentContexts = [ + { + schema: TRACKING_CONTEXT_SCHEMA, + data: { experiment: 'experiment1', variant: 'control' }, + }, + { + schema: TRACKING_CONTEXT_SCHEMA, + data: { experiment: 'experiment_two', variant: 'candidate' }, + }, + ]; + + beforeEach(() => { + getAllExperimentContexts.mockReturnValue(experimentContexts); + }); + + it('includes those contexts alongside the standard context', () => { + initDefaultTrackers(); + expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [ + standardContext, + ...experimentContexts, + ]); + }); + }); + }); +}); diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking/tracking_spec.js index 21fed51ff10..b7a2e4f4f51 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking/tracking_spec.js @@ -8,16 +8,16 @@ import getStandardContext from '~/tracking/get_standard_context'; jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn(), - getAllExperimentContexts: jest.fn(), + getAllExperimentContexts: jest.fn().mockReturnValue([]), })); +const TEST_CATEGORY = 'root:index'; +const TEST_ACTION = 'generic'; +const TEST_LABEL = 'button'; + describe('Tracking', () => { let standardContext; let snowplowSpy; - let bindDocumentSpy; - let trackLoadEventsSpy; - let enableFormTracking; - let setAnonymousUrlsSpy; beforeAll(() => { window.gl = window.gl || {}; @@ -30,132 +30,46 @@ describe('Tracking', () => { extra: {}, }, }; + window.snowplowOptions = { + namespace: 'gl_test', + hostname: 'app.test.com', + cookieDomain: '.test.com', + formTracking: true, + linkClickTracking: true, + formTrackingConfig: { forms: { allow: ['foo'] }, fields: { allow: ['bar'] } }, + }; standardContext = getStandardContext(); + window.snowplow = window.snowplow || (() => {}); + document.body.dataset.page = TEST_CATEGORY; + + initUserTracking(); + initDefaultTrackers(); }); beforeEach(() => { getExperimentData.mockReturnValue(undefined); getAllExperimentContexts.mockReturnValue([]); - window.snowplow = window.snowplow || (() => {}); - window.snowplowOptions = { - namespace: '_namespace_', - hostname: 'app.gitfoo.com', - cookieDomain: '.gitfoo.com', - }; snowplowSpy = jest.spyOn(window, 'snowplow'); }); - describe('initUserTracking', () => { - it('calls through to get a new tracker with the expected options', () => { - initUserTracking(); - expect(snowplowSpy).toHaveBeenCalledWith('newTracker', '_namespace_', 'app.gitfoo.com', { - namespace: '_namespace_', - hostname: 'app.gitfoo.com', - cookieDomain: '.gitfoo.com', - appId: '', - userFingerprint: false, - respectDoNotTrack: true, - forceSecureTracker: true, - eventMethod: 'post', - contexts: { webPage: true, performanceTiming: true }, - formTracking: false, - linkClickTracking: false, - pageUnloadTimer: 10, - formTrackingConfig: { - fields: { allow: [] }, - forms: { allow: [] }, - }, - }); - }); - }); - - describe('initDefaultTrackers', () => { - beforeEach(() => { - bindDocumentSpy = jest.spyOn(Tracking, 'bindDocument').mockImplementation(() => null); - trackLoadEventsSpy = jest.spyOn(Tracking, 'trackLoadEvents').mockImplementation(() => null); - enableFormTracking = jest - .spyOn(Tracking, 'enableFormTracking') - .mockImplementation(() => null); - setAnonymousUrlsSpy = jest.spyOn(Tracking, 'setAnonymousUrls').mockImplementation(() => null); - }); - - it('should activate features based on what has been enabled', () => { - initDefaultTrackers(); - expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', 30, 30); - expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [standardContext]); - expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking'); - expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking'); - - window.snowplowOptions = { - ...window.snowplowOptions, - formTracking: true, - linkClickTracking: true, - formTrackingConfig: { forms: { whitelist: ['foo'] }, fields: { whitelist: ['bar'] } }, - }; - - initDefaultTrackers(); - expect(enableFormTracking).toHaveBeenCalledWith(window.snowplowOptions.formTrackingConfig); - expect(snowplowSpy).toHaveBeenCalledWith('enableLinkClickTracking'); - }); - - it('binds the document event handling', () => { - initDefaultTrackers(); - expect(bindDocumentSpy).toHaveBeenCalled(); - }); - - it('tracks page loaded events', () => { - initDefaultTrackers(); - expect(trackLoadEventsSpy).toHaveBeenCalled(); - }); - - it('calls the anonymized URLs method', () => { - initDefaultTrackers(); - expect(setAnonymousUrlsSpy).toHaveBeenCalled(); - }); - - describe('when there are experiment contexts', () => { - const experimentContexts = [ - { - schema: TRACKING_CONTEXT_SCHEMA, - data: { experiment: 'experiment1', variant: 'control' }, - }, - { - schema: TRACKING_CONTEXT_SCHEMA, - data: { experiment: 'experiment_two', variant: 'candidate' }, - }, - ]; - - beforeEach(() => { - getAllExperimentContexts.mockReturnValue(experimentContexts); - }); - - it('includes those contexts alongside the standard context', () => { - initDefaultTrackers(); - expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [ - standardContext, - ...experimentContexts, - ]); - }); - }); - }); - describe('.event', () => { afterEach(() => { window.doNotTrack = undefined; navigator.doNotTrack = undefined; navigator.msDoNotTrack = undefined; + jest.clearAllMocks(); }); it('tracks to snowplow (our current tracking system)', () => { - Tracking.event('_category_', '_eventName_', { label: '_label_' }); + Tracking.event(TEST_CATEGORY, TEST_ACTION, { label: TEST_LABEL }); expect(snowplowSpy).toHaveBeenCalledWith( 'trackStructEvent', - '_category_', - '_eventName_', - '_label_', + TEST_CATEGORY, + TEST_ACTION, + TEST_LABEL, undefined, undefined, [standardContext], @@ -165,12 +79,12 @@ describe('Tracking', () => { it('allows adding extra data to the default context', () => { const extra = { foo: 'bar' }; - Tracking.event('_category_', '_eventName_', { extra }); + Tracking.event(TEST_CATEGORY, TEST_ACTION, { extra }); expect(snowplowSpy).toHaveBeenCalledWith( 'trackStructEvent', - '_category_', - '_eventName_', + TEST_CATEGORY, + TEST_ACTION, undefined, undefined, undefined, @@ -188,28 +102,28 @@ describe('Tracking', () => { it('skips tracking if snowplow is unavailable', () => { window.snowplow = false; - Tracking.event('_category_', '_eventName_'); + Tracking.event(TEST_CATEGORY, TEST_ACTION); expect(snowplowSpy).not.toHaveBeenCalled(); }); it('skips tracking if the user does not want to be tracked (general spec)', () => { window.doNotTrack = '1'; - Tracking.event('_category_', '_eventName_'); + Tracking.event(TEST_CATEGORY, TEST_ACTION); expect(snowplowSpy).not.toHaveBeenCalled(); }); it('skips tracking if the user does not want to be tracked (firefox legacy)', () => { navigator.doNotTrack = 'yes'; - Tracking.event('_category_', '_eventName_'); + Tracking.event(TEST_CATEGORY, TEST_ACTION); expect(snowplowSpy).not.toHaveBeenCalled(); }); it('skips tracking if the user does not want to be tracked (IE legacy)', () => { navigator.msDoNotTrack = '1'; - Tracking.event('_category_', '_eventName_'); + Tracking.event(TEST_CATEGORY, TEST_ACTION); expect(snowplowSpy).not.toHaveBeenCalled(); }); @@ -237,7 +151,7 @@ describe('Tracking', () => { ); }); - it('does not add empty form whitelist rules', () => { + it('does not add empty form allow rules', () => { Tracking.enableFormTracking({ fields: { allow: ['input-class1'] } }); expect(snowplowSpy).toHaveBeenCalledWith( @@ -287,7 +201,7 @@ describe('Tracking', () => { describe('.flushPendingEvents', () => { it('flushes any pending events', () => { Tracking.initialized = false; - Tracking.event('_category_', '_eventName_', { label: '_label_' }); + Tracking.event(TEST_CATEGORY, TEST_ACTION, { label: TEST_LABEL }); expect(snowplowSpy).not.toHaveBeenCalled(); @@ -295,9 +209,9 @@ describe('Tracking', () => { expect(snowplowSpy).toHaveBeenCalledWith( 'trackStructEvent', - '_category_', - '_eventName_', - '_label_', + TEST_CATEGORY, + TEST_ACTION, + TEST_LABEL, undefined, undefined, [standardContext], @@ -332,14 +246,13 @@ describe('Tracking', () => { }); }); - it('appends the hash/fragment to the pseudonymized URL', () => { - const hash = 'first-heading'; + it('does not appends the hash/fragment to the pseudonymized URL', () => { window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; - window.location.hash = hash; + window.location.hash = 'first-heading'; Tracking.setAnonymousUrls(); - expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', `${TEST_HOST}#${hash}`); + expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', TEST_HOST); }); it('does not set the referrer URL by default', () => { @@ -409,84 +322,79 @@ describe('Tracking', () => { }); }); - describe.each` - term - ${'event'} - ${'action'} - `('tracking interface events with data-track-$term', ({ term }) => { + describe('tracking interface events with data-track-action', () => { let eventSpy; beforeEach(() => { eventSpy = jest.spyOn(Tracking, 'event'); - Tracking.bindDocument('_category_'); // only happens once setHTMLFixture(` - <input data-track-${term}="click_input1" data-track-label="_label_" value=0 /> - <input data-track-${term}="click_input2" data-track-value=0 value=0/> - <input type="checkbox" data-track-${term}="toggle_checkbox" value=1 checked/> - <input class="dropdown" data-track-${term}="toggle_dropdown"/> - <div data-track-${term}="nested_event"><span class="nested"></span></div> - <input data-track-bogus="click_bogusinput" data-track-label="_label_" value="_value_"/> - <input data-track-${term}="click_input3" data-track-experiment="example" value="_value_"/> - <input data-track-${term}="event_with_extra" data-track-extra='{ "foo": "bar" }' /> - <input data-track-${term}="event_with_invalid_extra" data-track-extra="invalid_json" /> + <input data-track-action="click_input1" data-track-label="button" value="0" /> + <input data-track-action="click_input2" data-track-value="0" value="0" /> + <input type="checkbox" data-track-action="toggle_checkbox" value=1 checked /> + <input class="dropdown" data-track-action="toggle_dropdown"/> + <div data-track-action="nested_event"><span class="nested"></span></div> + <input data-track-bogus="click_bogusinput" data-track-label="button" value="1" /> + <input data-track-action="click_input3" data-track-experiment="example" value="1" /> + <input data-track-action="event_with_extra" data-track-extra='{ "foo": "bar" }' /> + <input data-track-action="event_with_invalid_extra" data-track-extra="invalid_json" /> `); }); - it(`binds to clicks on elements matching [data-track-${term}]`, () => { - document.querySelector(`[data-track-${term}="click_input1"]`).click(); + it(`binds to clicks on elements matching [data-track-action]`, () => { + document.querySelector(`[data-track-action="click_input1"]`).click(); - expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input1', { - label: '_label_', + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'click_input1', { + label: TEST_LABEL, value: '0', }); }); - it(`does not bind to clicks on elements without [data-track-${term}]`, () => { + it(`does not bind to clicks on elements without [data-track-action]`, () => { document.querySelector('[data-track-bogus="click_bogusinput"]').click(); expect(eventSpy).not.toHaveBeenCalled(); }); it('allows value override with the data-track-value attribute', () => { - document.querySelector(`[data-track-${term}="click_input2"]`).click(); + document.querySelector(`[data-track-action="click_input2"]`).click(); - expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input2', { + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'click_input2', { value: '0', }); }); it('handles checkbox values correctly', () => { - const checkbox = document.querySelector(`[data-track-${term}="toggle_checkbox"]`); + const checkbox = document.querySelector(`[data-track-action="toggle_checkbox"]`); checkbox.click(); // unchecking - expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', { + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'toggle_checkbox', { value: 0, }); checkbox.click(); // checking - expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', { + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'toggle_checkbox', { value: '1', }); }); it('handles bootstrap dropdowns', () => { - const dropdown = document.querySelector(`[data-track-${term}="toggle_dropdown"]`); + const dropdown = document.querySelector(`[data-track-action="toggle_dropdown"]`); dropdown.dispatchEvent(new Event('show.bs.dropdown', { bubbles: true })); - expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_dropdown_show', {}); + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'toggle_dropdown_show', {}); dropdown.dispatchEvent(new Event('hide.bs.dropdown', { bubbles: true })); - expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_dropdown_hide', {}); + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'toggle_dropdown_hide', {}); }); it('handles nested elements inside an element with tracking', () => { document.querySelector('span.nested').click(); - expect(eventSpy).toHaveBeenCalledWith('_category_', 'nested_event', {}); + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'nested_event', {}); }); it('includes experiment data if linked to an experiment', () => { @@ -497,54 +405,50 @@ describe('Tracking', () => { }; getExperimentData.mockReturnValue(mockExperimentData); - document.querySelector(`[data-track-${term}="click_input3"]`).click(); + document.querySelector(`[data-track-action="click_input3"]`).click(); - expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input3', { - value: '_value_', + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'click_input3', { + value: '1', context: { schema: TRACKING_CONTEXT_SCHEMA, data: mockExperimentData }, }); }); it('supports extra data as JSON', () => { - document.querySelector(`[data-track-${term}="event_with_extra"]`).click(); + document.querySelector(`[data-track-action="event_with_extra"]`).click(); - expect(eventSpy).toHaveBeenCalledWith('_category_', 'event_with_extra', { + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'event_with_extra', { extra: { foo: 'bar' }, }); }); it('ignores extra if provided JSON is invalid', () => { - document.querySelector(`[data-track-${term}="event_with_invalid_extra"]`).click(); + document.querySelector(`[data-track-action="event_with_invalid_extra"]`).click(); - expect(eventSpy).toHaveBeenCalledWith('_category_', 'event_with_invalid_extra', {}); + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'event_with_invalid_extra', {}); }); }); - describe.each` - term - ${'event'} - ${'action'} - `('tracking page loaded events with -$term', ({ term }) => { + describe('tracking page loaded events with -action', () => { let eventSpy; beforeEach(() => { eventSpy = jest.spyOn(Tracking, 'event'); setHTMLFixture(` - <div data-track-${term}="click_link" data-track-label="all_nested_links"> - <input data-track-${term}="render" data-track-label="label1" value=1 data-track-property="_property_"/> - <span data-track-${term}="render" data-track-label="label2" data-track-value=1> + <div data-track-action="click_link" data-track-label="all_nested_links"> + <input data-track-action="render" data-track-label="label1" value=1 data-track-property="_property_" /> + <span data-track-action="render" data-track-label="label2" data-track-value="1"> <a href="#" id="link">Something</a> </span> - <input data-track-${term}="_render_bogus_" data-track-label="label3" value="_value_" data-track-property="_property_"/> + <input data-track-action="_render_bogus_" data-track-label="label3" value="_value_" data-track-property="_property_" /> </div> `); - Tracking.trackLoadEvents('_category_'); // only happens once + Tracking.trackLoadEvents(TEST_CATEGORY); }); - it(`sends tracking events when [data-track-${term}="render"] is on an element`, () => { + it(`sends tracking events when [data-track-action="render"] is on an element`, () => { expect(eventSpy.mock.calls).toEqual([ [ - '_category_', + TEST_CATEGORY, 'render', { label: 'label1', @@ -553,7 +457,7 @@ describe('Tracking', () => { }, ], [ - '_category_', + TEST_CATEGORY, 'render', { label: 'label2', @@ -576,16 +480,16 @@ describe('Tracking', () => { eventSpy.mockClear(); }); - it(`avoids using ancestor [data-track-${term}="render"] tracking configurations`, () => { + it(`avoids using ancestor [data-track-action="render"] tracking configurations`, () => { link.dispatchEvent(new Event(event, { bubbles: true })); expect(eventSpy).not.toHaveBeenCalledWith( - '_category_', + TEST_CATEGORY, `render${actionSuffix}`, expect.any(Object), ); expect(eventSpy).toHaveBeenCalledWith( - '_category_', + TEST_CATEGORY, `click_link${actionSuffix}`, expect.objectContaining({ label: 'all_nested_links' }), ); diff --git a/spec/frontend/tracking/utils_spec.js b/spec/frontend/tracking/utils_spec.js new file mode 100644 index 00000000000..d6f2c5095b4 --- /dev/null +++ b/spec/frontend/tracking/utils_spec.js @@ -0,0 +1,99 @@ +import { + renameKey, + getReferrersCache, + addExperimentContext, + addReferrersCacheEntry, + filterOldReferrersCacheEntries, +} from '~/tracking/utils'; +import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; +import { REFERRER_TTL, URLS_CACHE_STORAGE_KEY } from '~/tracking/constants'; +import { TEST_HOST } from 'helpers/test_constants'; + +jest.mock('~/experimentation/utils', () => ({ + getExperimentData: jest.fn().mockReturnValue({}), +})); + +describe('~/tracking/utils', () => { + beforeEach(() => { + window.gl = window.gl || {}; + window.gl.snowplowStandardContext = {}; + }); + + describe('addExperimentContext', () => { + const options = { + category: 'root:index', + action: 'generic', + }; + + it('returns same options if no experiment is provided', () => { + expect(addExperimentContext({ options })).toStrictEqual({ options }); + }); + + it('adds experiment if provided', () => { + const experiment = 'TEST_EXPERIMENT_NAME'; + + expect(addExperimentContext({ experiment, ...options })).toStrictEqual({ + ...options, + context: { data: {}, schema: TRACKING_CONTEXT_SCHEMA }, + }); + }); + }); + + describe('renameKey', () => { + it('renames a given key', () => { + expect(renameKey({ allow: [] }, 'allow', 'permit')).toStrictEqual({ permit: [] }); + }); + }); + + describe('referrers cache', () => { + describe('filterOldReferrersCacheEntries', () => { + it('removes entries with old or no timestamp', () => { + const now = Date.now(); + const cache = [{ timestamp: now }, { timestamp: now - REFERRER_TTL }, { referrer: '' }]; + + expect(filterOldReferrersCacheEntries(cache)).toStrictEqual([{ timestamp: now }]); + }); + }); + + describe('getReferrersCache', () => { + beforeEach(() => { + localStorage.removeItem(URLS_CACHE_STORAGE_KEY); + }); + + it('returns an empty array if cache is not found', () => { + expect(getReferrersCache()).toHaveLength(0); + }); + + it('returns an empty array if cache is invalid', () => { + localStorage.setItem(URLS_CACHE_STORAGE_KEY, 'Invalid JSON'); + + expect(getReferrersCache()).toHaveLength(0); + }); + + it('returns parsed entries if valid', () => { + localStorage.setItem( + URLS_CACHE_STORAGE_KEY, + JSON.stringify([{ referrer: '', timestamp: Date.now() }]), + ); + + expect(getReferrersCache()).toHaveLength(1); + }); + }); + + describe('addReferrersCacheEntry', () => { + it('unshifts entry and adds timestamp', () => { + const now = Date.now(); + + addReferrersCacheEntry([{ referrer: '', originalUrl: TEST_HOST, timestamp: now }], { + referrer: TEST_HOST, + }); + + const cache = getReferrersCache(); + + expect(cache).toHaveLength(2); + expect(cache[0].referrer).toBe(TEST_HOST); + expect(cache[0].timestamp).toBeDefined(); + }); + }); + }); +}); diff --git a/spec/frontend/users_select/test_helper.js b/spec/frontend/users_select/test_helper.js index c5adbe9bb09..59edde48eab 100644 --- a/spec/frontend/users_select/test_helper.js +++ b/spec/frontend/users_select/test_helper.js @@ -1,6 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import { memoize, cloneDeep } from 'lodash'; -import { getFixture, getJSONFixture } from 'helpers/fixtures'; +import usersFixture from 'test_fixtures/autocomplete/users.json'; +import { getFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import UsersSelect from '~/users_select'; @@ -15,7 +16,7 @@ const getUserSearchHTML = memoize((fixturePath) => { return el.outerHTML; }); -const getUsersFixture = memoize(() => getJSONFixture('autocomplete/users.json')); +const getUsersFixture = () => usersFixture; export const getUsersFixtureAt = (idx) => getUsersFixture()[idx]; diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js index ef712ec23a6..c9dea4394f9 100644 --- a/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js @@ -61,9 +61,7 @@ describe('MRWidget approvals summary', () => { it('render message', () => { const names = toNounSeriesText(testRulesLeft()); - expect(wrapper.text()).toContain( - `Requires ${TEST_APPROVALS_LEFT} more approvals from ${names}.`, - ); + expect(wrapper.text()).toContain(`Requires ${TEST_APPROVALS_LEFT} approvals from ${names}.`); }); }); @@ -75,7 +73,9 @@ describe('MRWidget approvals summary', () => { }); it('renders message', () => { - expect(wrapper.text()).toContain(`Requires ${TEST_APPROVALS_LEFT} more approvals.`); + expect(wrapper.text()).toContain( + `Requires ${TEST_APPROVALS_LEFT} approvals from eligible users`, + ); }); }); diff --git a/spec/frontend/vue_mr_widget/components/extensions/actions_spec.js b/spec/frontend/vue_mr_widget/components/extensions/actions_spec.js new file mode 100644 index 00000000000..d5d779d7a34 --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/extensions/actions_spec.js @@ -0,0 +1,35 @@ +import { GlButton, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Actions from '~/vue_merge_request_widget/components/extensions/actions.vue'; + +let wrapper; + +function factory(propsData = {}) { + wrapper = shallowMount(Actions, { + propsData: { ...propsData, widget: 'test' }, + }); +} + +describe('MR widget extension actions', () => { + afterEach(() => { + wrapper.destroy(); + }); + + describe('tertiaryButtons', () => { + it('renders buttons', () => { + factory({ + tertiaryButtons: [{ text: 'hello world', href: 'https://gitlab.com', target: '_blank' }], + }); + + expect(wrapper.findAllComponents(GlButton)).toHaveLength(1); + }); + + it('renders tertiary actions in dropdown', () => { + factory({ + tertiaryButtons: [{ text: 'hello world', href: 'https://gitlab.com', target: '_blank' }], + }); + + expect(wrapper.findAllComponents(GlDropdownItem)).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/extensions/index_spec.js b/spec/frontend/vue_mr_widget/components/extensions/index_spec.js index 8f6fe3cd37a..63df63a9b00 100644 --- a/spec/frontend/vue_mr_widget/components/extensions/index_spec.js +++ b/spec/frontend/vue_mr_widget/components/extensions/index_spec.js @@ -1,4 +1,7 @@ -import { registerExtension, extensions } from '~/vue_merge_request_widget/components/extensions'; +import { + registerExtension, + registeredExtensions, +} from '~/vue_merge_request_widget/components/extensions'; import ExtensionBase from '~/vue_merge_request_widget/components/extensions/base.vue'; describe('MR widget extension registering', () => { @@ -14,7 +17,7 @@ describe('MR widget extension registering', () => { }, }); - expect(extensions[0]).toEqual( + expect(registeredExtensions.extensions[0]).toEqual( expect.objectContaining({ extends: ExtensionBase, name: 'Test', diff --git a/spec/frontend/vue_mr_widget/components/extensions/status_icon_spec.js b/spec/frontend/vue_mr_widget/components/extensions/status_icon_spec.js new file mode 100644 index 00000000000..f3aa5bb774f --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/extensions/status_icon_spec.js @@ -0,0 +1,36 @@ +import { GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; + +let wrapper; + +function factory(propsData = {}) { + wrapper = shallowMount(StatusIcon, { + propsData, + }); +} + +describe('MR widget extensions status icon', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('renders loading icon', () => { + factory({ name: 'test', isLoading: true, iconName: 'failed' }); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + it('renders status icon', () => { + factory({ name: 'test', isLoading: false, iconName: 'failed' }); + + expect(wrapper.findComponent(GlIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlIcon).props('name')).toBe('status-failed'); + }); + + it('sets aria-label for status icon', () => { + factory({ name: 'test', isLoading: false, iconName: 'failed' }); + + expect(wrapper.findComponent(GlIcon).props('ariaLabel')).toBe('Failed test'); + }); +}); 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 5ec719b17d6..efe2bf75c3f 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 @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; 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'; @@ -12,12 +13,14 @@ describe('MrWidgetPipelineContainer', () => { let mock; const factory = (props = {}) => { - wrapper = mount(MrWidgetPipelineContainer, { - propsData: { - mr: { ...mockStore }, - ...props, - }, - }); + wrapper = extendedWrapper( + mount(MrWidgetPipelineContainer, { + propsData: { + mr: { ...mockStore }, + ...props, + }, + }), + ); }; beforeEach(() => { @@ -30,6 +33,7 @@ describe('MrWidgetPipelineContainer', () => { }); const findDeploymentList = () => wrapper.findComponent(DeploymentList); + const findCIErrorMessage = () => wrapper.findByTestId('ci-error-message'); describe('when pre merge', () => { beforeEach(() => { @@ -69,15 +73,21 @@ describe('MrWidgetPipelineContainer', () => { beforeEach(() => { factory({ isPostMerge: true, + mr: { + ...mockStore, + pipeline: {}, + ciStatus: undefined, + }, }); }); it('renders pipeline', () => { expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true); + expect(findCIErrorMessage().exists()).toBe(false); expect(wrapper.find(MrWidgetPipeline).props()).toMatchObject({ pipeline: mockStore.mergePipeline, pipelineCoverageDelta: mockStore.pipelineCoverageDelta, - ciStatus: mockStore.ciStatus, + ciStatus: mockStore.mergePipeline.details.status.text, hasCi: mockStore.hasCI, sourceBranch: mockStore.targetBranch, sourceBranchLink: mockStore.targetBranch, @@ -92,7 +102,6 @@ describe('MrWidgetPipelineContainer', () => { targetBranch: 'Foo<script>alert("XSS")</script>', }, }); - expect(wrapper.find(MrWidgetPipeline).props().sourceBranchLink).toBe('Foo'); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js index b31a75f30d3..2ff94a547f4 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { GlSprintf } from '@gitlab/ui'; import CommitsHeader from '~/vue_merge_request_widget/components/states/commits_header.vue'; describe('Commits header component', () => { @@ -6,6 +7,9 @@ describe('Commits header component', () => { const createComponent = (props) => { wrapper = shallowMount(CommitsHeader, { + stubs: { + GlSprintf, + }, propsData: { isSquashEnabled: false, targetBranch: 'main', diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index e41fb815c8d..f0fbb1d5851 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -45,6 +45,8 @@ const createTestMr = (customConfig) => { preferredAutoMergeStrategy: MWPS_MERGE_STRATEGY, availableAutoMergeStrategies: [MWPS_MERGE_STRATEGY], mergeImmediatelyDocsPath: 'path/to/merge/immediately/docs', + transitionStateMachine: (transition) => eventHub.$emit('StateMachineValueChanged', transition), + translateStateToMachine: () => this.transitionStateMachine(), }; Object.assign(mr, customConfig.mr); @@ -304,6 +306,9 @@ describe('ReadyToMerge', () => { setImmediate(() => { expect(wrapper.vm.isMakingRequest).toBeTruthy(); expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + expect(eventHub.$emit).toHaveBeenCalledWith('StateMachineValueChanged', { + transition: 'start-auto-merge', + }); const params = wrapper.vm.service.merge.mock.calls[0][0]; @@ -341,10 +346,15 @@ describe('ReadyToMerge', () => { it('should handle merge action accepted case', (done) => { createComponent(); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); jest.spyOn(wrapper.vm.service, 'merge').mockReturnValue(returnPromise('success')); jest.spyOn(wrapper.vm, 'initiateMergePolling').mockImplementation(() => {}); wrapper.vm.handleMergeButtonClick(); + expect(eventHub.$emit).toHaveBeenCalledWith('StateMachineValueChanged', { + transition: 'start-merge', + }); + setImmediate(() => { expect(wrapper.vm.isMakingRequest).toBeTruthy(); expect(wrapper.vm.initiateMergePolling).toHaveBeenCalled(); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js index 61e44140efc..be15e4df66d 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js @@ -1,9 +1,9 @@ import Vue from 'vue'; -import createFlash from '~/flash'; import WorkInProgress from '~/vue_merge_request_widget/components/states/work_in_progress.vue'; +import toast from '~/vue_shared/plugins/global_toast'; import eventHub from '~/vue_merge_request_widget/event_hub'; -jest.mock('~/flash'); +jest.mock('~/vue_shared/plugins/global_toast'); const createComponent = () => { const Component = Vue.extend(WorkInProgress); @@ -63,10 +63,7 @@ describe('Wip', () => { setImmediate(() => { expect(vm.isMakingRequest).toBeTruthy(); expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj); - expect(createFlash).toHaveBeenCalledWith({ - message: 'Marked as ready. Merging is now allowed.', - type: 'notice', - }); + expect(toast).toHaveBeenCalledWith('Marked as ready. Merging is now allowed.'); done(); }); }); diff --git a/spec/frontend/vue_mr_widget/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js index f356f6fb5bf..34a741cf8f2 100644 --- a/spec/frontend/vue_mr_widget/mock_data.js +++ b/spec/frontend/vue_mr_widget/mock_data.js @@ -280,7 +280,7 @@ export default { merge_train_index: 1, security_reports_docs_path: 'security-reports-docs-path', sast_comparison_path: '/sast_comparison_path', - secret_scanning_comparison_path: '/secret_scanning_comparison_path', + secret_detection_comparison_path: '/secret_detection_comparison_path', gitpod_enabled: true, show_gitpod_button: true, gitpod_url: 'http://gitpod.localhost', diff --git a/spec/frontend/vue_mr_widget/mr_widget_how_to_merge_modal_spec.js b/spec/frontend/vue_mr_widget/mr_widget_how_to_merge_modal_spec.js index bd22183cbea..913d5860b48 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_how_to_merge_modal_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_how_to_merge_modal_spec.js @@ -8,11 +8,9 @@ describe('MRWidgetHowToMerge', () => { function mountComponent({ data = {}, props = {} } = {}) { wrapper = shallowMount(MrWidgetHowToMergeModal, { data() { - return { ...data }; - }, - propsData: { - ...props, + return data; }, + propsData: props, stubs: {}, }); } @@ -57,4 +55,16 @@ describe('MRWidgetHowToMerge', () => { mountComponent({ props: { isFork: true } }); expect(findInstructionsFields().at(0).text()).toContain('FETCH_HEAD'); }); + + it('escapes the target branch name shell-secure', () => { + mountComponent({ props: { targetBranch: '";echo$IFS"you_shouldnt_run_this' } }); + + expect(findInstructionsFields().at(1).text()).toContain('\'";echo$IFS"you_shouldnt_run_this\''); + }); + + it('escapes the source branch name shell-secure', () => { + mountComponent({ props: { sourceBranch: 'branch-of-$USER' } }); + + expect(findInstructionsFields().at(0).text()).toContain("'branch-of-$USER'"); + }); }); 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 c50cf7cb076..5aba6982886 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -1,13 +1,19 @@ +import { GlBadge, GlLink, GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data'; import axios from '~/lib/utils/axios_utils'; import { setFaviconOverlay } from '~/lib/utils/favicon'; import notify from '~/lib/utils/notify'; import SmartInterval from '~/smart_interval'; +import { + registerExtension, + registeredExtensions, +} from '~/vue_merge_request_widget/components/extensions'; import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants'; import eventHub from '~/vue_merge_request_widget/event_hub'; import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue'; @@ -15,6 +21,7 @@ import { stateKey } from '~/vue_merge_request_widget/stores/state_maps'; import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql'; import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data'; import mockData from './mock_data'; +import testExtension from './test_extension'; jest.mock('~/smart_interval'); @@ -879,4 +886,48 @@ describe('MrWidgetOptions', () => { }); }); }); + + describe('mock extension', () => { + beforeEach(() => { + registerExtension(testExtension); + + createComponent(); + }); + + afterEach(() => { + registeredExtensions.extensions = []; + }); + + it('renders collapsed data', async () => { + await waitForPromises(); + + expect(wrapper.text()).toContain('Test extension summary count: 1'); + }); + + it('renders full data', async () => { + await waitForPromises(); + + wrapper + .find('[data-testid="widget-extension"] [data-testid="toggle-button"]') + .trigger('click'); + + await Vue.nextTick(); + + const collapsedSection = wrapper.find('[data-testid="widget-extension-collapsed-section"]'); + expect(collapsedSection.exists()).toBe(true); + expect(collapsedSection.text()).toContain('Hello world'); + + // Renders icon in the row + expect(collapsedSection.find(GlIcon).exists()).toBe(true); + expect(collapsedSection.find(GlIcon).props('name')).toBe('status-failed'); + + // Renders badge in the row + expect(collapsedSection.find(GlBadge).exists()).toBe(true); + expect(collapsedSection.find(GlBadge).text()).toBe('Closed'); + + // Renders a link in the row + expect(collapsedSection.find(GlLink).exists()).toBe(true); + expect(collapsedSection.find(GlLink).text()).toBe('GitLab.com'); + }); + }); }); diff --git a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js index bf0179aa425..febcfcd4019 100644 --- a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js +++ b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js @@ -162,7 +162,7 @@ describe('MergeRequestStore', () => { expect(store.securityReportsDocsPath).toBe('security-reports-docs-path'); }); - it.each(['sast_comparison_path', 'secret_scanning_comparison_path'])( + it.each(['sast_comparison_path', 'secret_detection_comparison_path'])( 'should set %s path', (property) => { // Ensure something is set in the mock data diff --git a/spec/frontend/vue_mr_widget/test_extension.js b/spec/frontend/vue_mr_widget/test_extension.js new file mode 100644 index 00000000000..a29a4d2fb46 --- /dev/null +++ b/spec/frontend/vue_mr_widget/test_extension.js @@ -0,0 +1,37 @@ +import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; + +export default { + name: 'WidgetTestExtension', + props: ['targetProjectFullPath'], + computed: { + summary({ count, targetProjectFullPath }) { + return `Test extension summary count: ${count} & ${targetProjectFullPath}`; + }, + statusIcon({ count }) { + return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success; + }, + }, + methods: { + fetchCollapsedData({ targetProjectFullPath }) { + return Promise.resolve({ targetProjectFullPath, count: 1 }); + }, + fetchFullData() { + return Promise.resolve([ + { + id: 1, + text: 'Hello world', + icon: { + name: EXTENSION_ICONS.failed, + }, + badge: { + text: 'Closed', + }, + link: { + href: 'https://gitlab.com', + text: 'GitLab.com', + }, + }, + ]); + }, + }, +}; diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap index c7758b0faef..44b4c0398cd 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap @@ -4,12 +4,12 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <gl-dropdown-stub category="primary" clearalltext="Clear all" + clearalltextclass="gl-px-5" headertext="" hideheaderborder="true" highlighteditemstitle="Selected" highlighteditemstitleclass="gl-px-5" right="true" - showhighlighteditemstitle="true" size="medium" text="Clone" variant="info" @@ -35,6 +35,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <b-form-input-stub class="gl-form-input" debounce="0" + formatter="[Function]" readonly="true" type="text" value="ssh://foo.bar" @@ -78,6 +79,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <b-form-input-stub class="gl-form-input" debounce="0" + formatter="[Function]" readonly="true" type="text" value="http://foo.bar" diff --git a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap index f2ff12b2acd..2b89e36344d 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap @@ -4,12 +4,12 @@ exports[`SplitButton renders actionItems 1`] = ` <gl-dropdown-stub category="primary" clearalltext="Clear all" + clearalltextclass="gl-px-5" headertext="" hideheaderborder="true" highlighteditemstitle="Selected" highlighteditemstitleclass="gl-px-5" menu-class="" - showhighlighteditemstitle="true" size="medium" split="true" text="professor" diff --git a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js index c6c351a7f3f..3277aab43f0 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js +++ b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js @@ -1,25 +1,16 @@ import { shallowMount } from '@vue/test-utils'; -import waitForPromises from 'helpers/wait_for_promises'; import { HIGHLIGHT_CLASS_NAME } from '~/vue_shared/components/blob_viewers/constants'; import SimpleViewer from '~/vue_shared/components/blob_viewers/simple_viewer.vue'; -import SourceEditor from '~/vue_shared/components/source_editor.vue'; describe('Blob Simple Viewer component', () => { let wrapper; const contentMock = `<span id="LC1">First</span>\n<span id="LC2">Second</span>\n<span id="LC3">Third</span>`; const blobHash = 'foo-bar'; - function createComponent( - content = contentMock, - isRawContent = false, - isRefactorFlagEnabled = false, - ) { + function createComponent(content = contentMock, isRawContent = false) { wrapper = shallowMount(SimpleViewer, { provide: { blobHash, - glFeatures: { - refactorBlobViewer: isRefactorFlagEnabled, - }, }, propsData: { content, @@ -94,32 +85,4 @@ describe('Blob Simple Viewer component', () => { }); }); }); - - describe('Vue refactoring to use Source Editor', () => { - const findSourceEditor = () => wrapper.find(SourceEditor); - - it.each` - doesRender | condition | isRawContent | isRefactorFlagEnabled - ${'Does not'} | ${'rawContent is not specified'} | ${false} | ${true} - ${'Does not'} | ${'feature flag is disabled is not specified'} | ${true} | ${false} - ${'Does not'} | ${'both, the FF and rawContent are not specified'} | ${false} | ${false} - ${'Does'} | ${'both, the FF and rawContent are specified'} | ${true} | ${true} - `( - '$doesRender render Source Editor component in readonly mode when $condition', - async ({ isRawContent, isRefactorFlagEnabled } = {}) => { - createComponent('raw content', isRawContent, isRefactorFlagEnabled); - await waitForPromises(); - - if (isRawContent && isRefactorFlagEnabled) { - expect(findSourceEditor().exists()).toBe(true); - - expect(findSourceEditor().props('value')).toBe('raw content'); - expect(findSourceEditor().props('fileName')).toBe('test.js'); - expect(findSourceEditor().props('editorOptions')).toEqual({ readOnly: true }); - } else { - expect(findSourceEditor().exists()).toBe(false); - } - }, - ); - }); }); diff --git a/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js index d30f36ec63c..fef50bdaccc 100644 --- a/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js +++ b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js @@ -111,15 +111,13 @@ describe('ColorPicker', () => { gon.suggested_label_colors = {}; createComponent(shallowMount); - expect(description()).toBe('Choose any color'); + expect(description()).toBe('Enter any color.'); expect(presetColors().exists()).toBe(false); }); it('shows the suggested colors', () => { createComponent(shallowMount); - expect(description()).toBe( - 'Choose any color. Or you can choose one of the suggested colors below', - ); + expect(description()).toBe('Enter any color or choose one of the suggested colors below.'); expect(presetColors()).toHaveLength(4); }); diff --git a/spec/frontend/vue_shared/components/dismissible_feedback_alert_spec.js b/spec/frontend/vue_shared/components/dismissible_feedback_alert_spec.js index 175d79dd1c2..194681a6138 100644 --- a/spec/frontend/vue_shared/components/dismissible_feedback_alert_spec.js +++ b/spec/frontend/vue_shared/components/dismissible_feedback_alert_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlAlert, GlSprintf } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import Component from '~/vue_shared/components/dismissible_feedback_alert.vue'; @@ -8,20 +8,13 @@ describe('Dismissible Feedback Alert', () => { let wrapper; - const defaultProps = { - featureName: 'Dependency List', - feedbackLink: 'https://gitlab.link', - }; - + const featureName = 'Dependency List'; const STORAGE_DISMISSAL_KEY = 'dependency_list_feedback_dismissed'; - const createComponent = ({ props, shallow } = {}) => { - const mountFn = shallow ? shallowMount : mount; - + const createComponent = ({ mountFn = shallowMount } = {}) => { wrapper = mountFn(Component, { propsData: { - ...defaultProps, - ...props, + featureName, }, stubs: { GlSprintf, @@ -34,8 +27,8 @@ describe('Dismissible Feedback Alert', () => { wrapper = null; }); - const findAlert = () => wrapper.find(GlAlert); - const findLink = () => wrapper.find(GlLink); + const createFullComponent = () => createComponent({ mountFn: mount }); + const findAlert = () => wrapper.findComponent(GlAlert); describe('with default', () => { beforeEach(() => { @@ -46,17 +39,6 @@ describe('Dismissible Feedback Alert', () => { expect(findAlert().exists()).toBe(true); }); - it('contains feature name', () => { - expect(findAlert().text()).toContain(defaultProps.featureName); - }); - - it('contains provided link', () => { - const link = findLink(); - - expect(link.attributes('href')).toBe(defaultProps.feedbackLink); - expect(link.attributes('target')).toBe('_blank'); - }); - it('should have the storage key set', () => { expect(wrapper.vm.storageKey).toBe(STORAGE_DISMISSAL_KEY); }); @@ -65,7 +47,7 @@ describe('Dismissible Feedback Alert', () => { describe('dismissible', () => { describe('after dismissal', () => { beforeEach(() => { - createComponent({ shallow: false }); + createFullComponent(); findAlert().vm.$emit('dismiss'); }); @@ -81,7 +63,7 @@ describe('Dismissible Feedback Alert', () => { describe('already dismissed', () => { it('should not show the alert once dismissed', async () => { localStorage.setItem(STORAGE_DISMISSAL_KEY, 'true'); - createComponent({ shallow: false }); + createFullComponent(); await wrapper.vm.$nextTick(); expect(findAlert().exists()).toBe(false); diff --git a/spec/frontend/vue_shared/components/dropdown_keyboard_navigation_spec.js b/spec/frontend/vue_shared/components/dropdown_keyboard_navigation_spec.js new file mode 100644 index 00000000000..996df34f2ff --- /dev/null +++ b/spec/frontend/vue_shared/components/dropdown_keyboard_navigation_spec.js @@ -0,0 +1,141 @@ +import { shallowMount } from '@vue/test-utils'; +import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; +import { UP_KEY_CODE, DOWN_KEY_CODE, TAB_KEY_CODE } from '~/lib/utils/keycodes'; + +const MOCK_INDEX = 0; +const MOCK_MAX = 10; +const MOCK_MIN = 0; +const MOCK_DEFAULT_INDEX = 0; + +describe('DropdownKeyboardNavigation', () => { + let wrapper; + + const defaultProps = { + index: MOCK_INDEX, + max: MOCK_MAX, + min: MOCK_MIN, + defaultIndex: MOCK_DEFAULT_INDEX, + }; + + const createComponent = (props) => { + wrapper = shallowMount(DropdownKeyboardNavigation, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const helpers = { + arrowDown: () => { + document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: DOWN_KEY_CODE })); + }, + arrowUp: () => { + document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: UP_KEY_CODE })); + }, + tab: () => { + document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: TAB_KEY_CODE })); + }, + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('onInit', () => { + beforeEach(() => { + createComponent(); + }); + + it('should $emit @change with the default index', async () => { + expect(wrapper.emitted('change')[0]).toStrictEqual([MOCK_DEFAULT_INDEX]); + }); + + it('should $emit @change with the default index when max changes', async () => { + wrapper.setProps({ max: 20 }); + await wrapper.vm.$nextTick(); + // The first @change`call happens on created() so we test for the second [1] + expect(wrapper.emitted('change')[1]).toStrictEqual([MOCK_DEFAULT_INDEX]); + }); + }); + + describe('keydown events', () => { + let incrementSpy; + + beforeEach(() => { + createComponent(); + incrementSpy = jest.spyOn(wrapper.vm, 'increment'); + }); + + afterEach(() => { + incrementSpy.mockRestore(); + }); + + it('onKeydown-Down calls increment(1)', () => { + helpers.arrowDown(); + + expect(incrementSpy).toHaveBeenCalledWith(1); + }); + + it('onKeydown-Up calls increment(-1)', () => { + helpers.arrowUp(); + + expect(incrementSpy).toHaveBeenCalledWith(-1); + }); + + it('onKeydown-Tab $emits @tab event', () => { + helpers.tab(); + + expect(wrapper.emitted('tab')).toHaveLength(1); + }); + }); + + describe('increment', () => { + describe('when max is 0', () => { + beforeEach(() => { + createComponent({ max: 0 }); + }); + + it('does not $emit any @change events', () => { + helpers.arrowDown(); + + // The first @change`call happens on created() so we test that we only have 1 call + expect(wrapper.emitted('change')).toHaveLength(1); + }); + }); + + describe.each` + keyboardAction | direction | index | max | min + ${helpers.arrowDown} | ${1} | ${10} | ${10} | ${0} + ${helpers.arrowUp} | ${-1} | ${0} | ${10} | ${0} + `('moving out of bounds', ({ keyboardAction, direction, index, max, min }) => { + beforeEach(() => { + createComponent({ index, max, min }); + keyboardAction(); + }); + + it(`in ${direction} direction does not $emit any @change events`, () => { + // The first @change`call happens on created() so we test that we only have 1 call + expect(wrapper.emitted('change')).toHaveLength(1); + }); + }); + + describe.each` + keyboardAction | direction | index | max | min + ${helpers.arrowDown} | ${1} | ${0} | ${10} | ${0} + ${helpers.arrowUp} | ${-1} | ${10} | ${10} | ${0} + `('moving in bounds', ({ keyboardAction, direction, index, max, min }) => { + beforeEach(() => { + createComponent({ index, max, min }); + keyboardAction(); + }); + + it(`in ${direction} direction $emits @change event with the correct index ${ + index + direction + }`, () => { + // The first @change`call happens on created() so we test for the second [1] + expect(wrapper.emitted('change')[1]).toStrictEqual([index + direction]); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index 134c6c8b929..ae02c554e13 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -141,7 +141,62 @@ export const mockEpicToken = { token: EpicToken, operators: OPERATOR_IS_ONLY, idProperty: 'iid', - fetchEpics: () => Promise.resolve({ data: mockEpics }), + fullPath: 'gitlab-org', +}; + +export const mockEpicNode1 = { + __typename: 'Epic', + parent: null, + id: 'gid://gitlab/Epic/40', + iid: '2', + title: 'Marketing epic', + description: 'Mock epic description', + state: 'opened', + startDate: '2017-12-25', + dueDate: '2018-02-15', + webUrl: 'http://gdk.test:3000/groups/gitlab-org/marketing/-/epics/1', + hasChildren: false, + hasParent: false, + confidential: false, +}; + +export const mockEpicNode2 = { + __typename: 'Epic', + parent: null, + id: 'gid://gitlab/Epic/41', + iid: '3', + title: 'Another marketing', + startDate: '2017-12-26', + dueDate: '2018-03-10', + state: 'opened', + webUrl: 'http://gdk.test:3000/groups/gitlab-org/marketing/-/epics/2', +}; + +export const mockGroupEpicsQueryResponse = { + data: { + group: { + id: 'gid://gitlab/Group/1', + name: 'Gitlab Org', + epics: { + edges: [ + { + node: { + ...mockEpicNode1, + }, + __typename: 'EpicEdge', + }, + { + node: { + ...mockEpicNode2, + }, + __typename: 'EpicEdge', + }, + ], + __typename: 'EpicConnection', + }, + __typename: 'Group', + }, + }, }; export const mockReactionEmojiToken = { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js index d3e1bfef561..14fcffd3c50 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js @@ -57,7 +57,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: 'custom-class', + suggestionsListClass: () => 'custom-class', }, data() { return { ...data }; diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js index eb1dbed52cc..f9ce0338d2f 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js @@ -67,7 +67,7 @@ function createComponent({ provide: { portalName: 'fake target', alignSuggestions: jest.fn(), - suggestionsListClass: 'custom-class', + suggestionsListClass: () => 'custom-class', }, stubs, slots, @@ -206,26 +206,50 @@ describe('BaseToken', () => { describe('events', () => { let wrapperWithNoStubs; - beforeEach(() => { - wrapperWithNoStubs = createComponent({ - stubs: { Portal: true }, - }); - }); - afterEach(() => { wrapperWithNoStubs.destroy(); }); - it('emits `fetch-suggestions` event on component after a delay when component emits `input` event', async () => { - jest.useFakeTimers(); + describe('when activeToken has been selected', () => { + beforeEach(() => { + wrapperWithNoStubs = createComponent({ + props: { + ...mockProps, + getActiveTokenValue: () => ({ title: '' }), + suggestionsLoading: true, + }, + stubs: { Portal: true }, + }); + }); + it('does not emit `fetch-suggestions` event on component after a delay when component emits `input` event', async () => { + jest.useFakeTimers(); - wrapperWithNoStubs.find(GlFilteredSearchToken).vm.$emit('input', { data: 'foo' }); - await wrapperWithNoStubs.vm.$nextTick(); + wrapperWithNoStubs.find(GlFilteredSearchToken).vm.$emit('input', { data: 'foo' }); + await wrapperWithNoStubs.vm.$nextTick(); - jest.runAllTimers(); + jest.runAllTimers(); - expect(wrapperWithNoStubs.emitted('fetch-suggestions')).toBeTruthy(); - expect(wrapperWithNoStubs.emitted('fetch-suggestions')[2]).toEqual(['foo']); + expect(wrapperWithNoStubs.emitted('fetch-suggestions')).toEqual([['']]); + }); + }); + + describe('when activeToken has not been selected', () => { + beforeEach(() => { + wrapperWithNoStubs = createComponent({ + stubs: { Portal: true }, + }); + }); + it('emits `fetch-suggestions` event on component after a delay when component emits `input` event', async () => { + jest.useFakeTimers(); + + wrapperWithNoStubs.find(GlFilteredSearchToken).vm.$emit('input', { data: 'foo' }); + await wrapperWithNoStubs.vm.$nextTick(); + + jest.runAllTimers(); + + expect(wrapperWithNoStubs.emitted('fetch-suggestions')).toBeTruthy(); + expect(wrapperWithNoStubs.emitted('fetch-suggestions')[2]).toEqual(['foo']); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js index 09eac636cae..f3e8b2d0c1b 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js @@ -42,7 +42,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: 'custom-class', + suggestionsListClass: () => 'custom-class', }, stubs, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js index c2d61fd9f05..36071c900df 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js @@ -48,7 +48,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: 'custom-class', + suggestionsListClass: () => 'custom-class', }, stubs, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js index 68ed46fc3a2..6ee5d50d396 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js @@ -1,15 +1,21 @@ -import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'; +import { GlFilteredSearchTokenSegment } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import Vue 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 axios from '~/lib/utils/axios_utils'; +import searchEpicsQuery from '~/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql'; import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { mockEpicToken, mockEpics } from '../mock_data'; +import { mockEpicToken, mockEpics, mockGroupEpicsQueryResponse } from '../mock_data'; jest.mock('~/flash'); +Vue.use(VueApollo); const defaultStubs = { Portal: true, @@ -21,31 +27,39 @@ const defaultStubs = { }, }; -function createComponent(options = {}) { - const { - config = mockEpicToken, - value = { data: '' }, - active = false, - stubs = defaultStubs, - } = options; - return mount(EpicToken, { - propsData: { - config, - value, - active, - }, - provide: { - portalName: 'fake target', - alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: 'custom-class', - }, - stubs, - }); -} - describe('EpicToken', () => { let mock; let wrapper; + let fakeApollo; + + const findBaseToken = () => wrapper.findComponent(BaseToken); + + function createComponent( + options = {}, + epicsQueryHandler = jest.fn().mockResolvedValue(mockGroupEpicsQueryResponse), + ) { + fakeApollo = createMockApollo([[searchEpicsQuery, epicsQueryHandler]]); + const { + config = mockEpicToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + } = options; + return mount(EpicToken, { + apolloProvider: fakeApollo, + propsData: { + config, + value, + active, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: 'custom-class', + }, + stubs, + }); + } beforeEach(() => { mock = new MockAdapter(axios); @@ -71,23 +85,20 @@ describe('EpicToken', () => { describe('methods', () => { describe('fetchEpicsBySearchTerm', () => { - it('calls `config.fetchEpics` with provided searchTerm param', () => { - jest.spyOn(wrapper.vm.config, 'fetchEpics'); + it('calls fetchEpics with provided searchTerm param', () => { + jest.spyOn(wrapper.vm, 'fetchEpics'); - wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' }); + findBaseToken().vm.$emit('fetch-suggestions', 'foo'); - expect(wrapper.vm.config.fetchEpics).toHaveBeenCalledWith({ - epicPath: '', - search: 'foo', - }); + expect(wrapper.vm.fetchEpics).toHaveBeenCalledWith('foo'); }); it('sets response to `epics` when request is successful', async () => { - jest.spyOn(wrapper.vm.config, 'fetchEpics').mockResolvedValue({ + jest.spyOn(wrapper.vm, 'fetchEpics').mockResolvedValue({ data: mockEpics, }); - wrapper.vm.fetchEpicsBySearchTerm({}); + findBaseToken().vm.$emit('fetch-suggestions'); await waitForPromises(); @@ -95,9 +106,9 @@ describe('EpicToken', () => { }); it('calls `createFlash` with flash error message when request fails', async () => { - jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({}); + jest.spyOn(wrapper.vm, 'fetchEpics').mockRejectedValue({}); - wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' }); + findBaseToken().vm.$emit('fetch-suggestions', 'foo'); await waitForPromises(); @@ -107,9 +118,9 @@ describe('EpicToken', () => { }); it('sets `loading` to false when request completes', async () => { - jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({}); + jest.spyOn(wrapper.vm, 'fetchEpics').mockRejectedValue({}); - wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' }); + findBaseToken().vm.$emit('fetch-suggestions', 'foo'); await waitForPromises(); @@ -123,15 +134,15 @@ describe('EpicToken', () => { beforeEach(async () => { wrapper = createComponent({ - value: { data: `${mockEpics[0].group_full_path}::&${mockEpics[0].iid}` }, + value: { data: `${mockEpics[0].title}::&${mockEpics[0].iid}` }, data: { epics: mockEpics }, }); await wrapper.vm.$nextTick(); }); - it('renders gl-filtered-search-token component', () => { - expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true); + it('renders BaseToken component', () => { + expect(findBaseToken().exists()).toBe(true); }); it('renders token item when value is selected', () => { @@ -142,9 +153,9 @@ describe('EpicToken', () => { }); it.each` - value | valueType | tokenValueString - ${`${mockEpics[0].group_full_path}::&${mockEpics[0].iid}`} | ${'string'} | ${`${mockEpics[0].title}::&${mockEpics[0].iid}`} - ${`${mockEpics[1].group_full_path}::&${mockEpics[1].iid}`} | ${'number'} | ${`${mockEpics[1].title}::&${mockEpics[1].iid}`} + value | valueType | tokenValueString + ${`${mockEpics[0].title}::&${mockEpics[0].iid}`} | ${'string'} | ${`${mockEpics[0].title}::&${mockEpics[0].iid}`} + ${`${mockEpics[1].title}::&${mockEpics[1].iid}`} | ${'number'} | ${`${mockEpics[1].title}::&${mockEpics[1].iid}`} `('renders token item when selection is a $valueType', async ({ value, tokenValueString }) => { wrapper.setProps({ value: { data: value }, diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js index a609aaa1c4e..af90ee93543 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js @@ -21,7 +21,7 @@ describe('IterationToken', () => { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: 'custom-class', + suggestionsListClass: () => 'custom-class', }, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js index a348344b9dd..f55fb2836e3 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js @@ -48,7 +48,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: 'custom-class', + suggestionsListClass: () => 'custom-class', }, stubs, listeners, diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js index bfb593bf82d..936841651d1 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -48,7 +48,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: 'custom-class', + suggestionsListClass: () => 'custom-class', }, stubs, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js index e788c742736..4277899f8db 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js @@ -19,7 +19,7 @@ describe('WeightToken', () => { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: 'custom-class', + suggestionsListClass: () => 'custom-class', }, }); diff --git a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js index 2658fa4a706..f74b9b37197 100644 --- a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js @@ -94,10 +94,6 @@ describe('IssueAssigneesComponent', () => { expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Assigned to Terrell Graham'); }); - it('renders component root element with class `issue-assignees`', () => { - expect(wrapper.element.classList.contains('issue-assignees')).toBe(true); - }); - it('renders assignee', () => { const data = findAvatars().wrappers.map((x) => ({ ...x.props(), 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 ba2450b56c9..9bc2aad1895 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 @@ -60,7 +60,7 @@ describe('Suggestion Diff component', () => { expect(findHelpButton().exists()).toBe(true); }); - it('renders apply suggestion and add to batch buttons', () => { + it('renders add to batch button when more than 1 suggestion', () => { createComponent({ suggestionsCount: 2, }); @@ -68,8 +68,7 @@ describe('Suggestion Diff component', () => { const applyBtn = findApplyButton(); const addToBatchBtn = findAddToBatchButton(); - expect(applyBtn.exists()).toBe(true); - expect(applyBtn.html().includes('Apply suggestion')).toBe(true); + expect(applyBtn.exists()).toBe(false); expect(addToBatchBtn.exists()).toBe(true); expect(addToBatchBtn.html().includes('Add suggestion to batch')).toBe(true); @@ -85,7 +84,7 @@ describe('Suggestion Diff component', () => { describe('when apply suggestion is clicked', () => { beforeEach(() => { - createComponent(); + createComponent({ batchSuggestionsCount: 0 }); findApplyButton().vm.$emit('apply'); }); @@ -140,11 +139,11 @@ describe('Suggestion Diff component', () => { describe('apply suggestions is clicked', () => { it('emits applyBatch', () => { - createComponent({ isBatched: true }); + createComponent({ isBatched: true, batchSuggestionsCount: 2 }); - findApplyBatchButton().vm.$emit('click'); + findApplyButton().vm.$emit('apply'); - expect(wrapper.emitted().applyBatch).toEqual([[]]); + expect(wrapper.emitted().applyBatch).toEqual([[undefined]]); }); }); @@ -155,23 +154,24 @@ describe('Suggestion Diff component', () => { isBatched: true, }); - const applyBatchBtn = findApplyBatchButton(); + const applyBatchBtn = findApplyButton(); const removeFromBatchBtn = findRemoveFromBatchButton(); expect(removeFromBatchBtn.exists()).toBe(true); expect(removeFromBatchBtn.html().includes('Remove from batch')).toBe(true); expect(applyBatchBtn.exists()).toBe(true); - expect(applyBatchBtn.html().includes('Apply suggestions')).toBe(true); + expect(applyBatchBtn.html().includes('Apply suggestion')).toBe(true); expect(applyBatchBtn.html().includes(String('9'))).toBe(true); }); it('hides add to batch and apply buttons', () => { createComponent({ isBatched: true, + batchSuggestionsCount: 9, }); - expect(findApplyButton().exists()).toBe(false); + expect(findApplyButton().exists()).toBe(true); expect(findAddToBatchButton().exists()).toBe(false); }); @@ -215,9 +215,8 @@ describe('Suggestion Diff component', () => { }); it('disables apply suggestion and hides add to batch button', () => { - expect(findApplyButton().exists()).toBe(true); + expect(findApplyButton().exists()).toBe(false); expect(findAddToBatchButton().exists()).toBe(false); - expect(findApplyButton().attributes('disabled')).toBe('true'); }); }); @@ -225,7 +224,7 @@ describe('Suggestion Diff component', () => { const findTooltip = () => getBinding(findApplyButton().element, 'gl-tooltip'); it('renders correct tooltip message when button is applicable', () => { - createComponent(); + createComponent({ batchSuggestionsCount: 0 }); const tooltip = findTooltip(); expect(tooltip.modifiers.viewport).toBe(true); @@ -234,7 +233,7 @@ describe('Suggestion Diff component', () => { it('renders the inapplicable reason in the tooltip when button is not applicable', () => { const inapplicableReason = 'lorem'; - createComponent({ canApply: false, inapplicableReason }); + createComponent({ canApply: false, inapplicableReason, batchSuggestionsCount: 0 }); const tooltip = findTooltip(); expect(tooltip.modifiers.viewport).toBe(true); diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js index 5bd6bda2d2c..af27e953776 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js @@ -77,7 +77,7 @@ describe('Suggestion Diff component', () => { it.each` event | childArgs | args ${'apply'} | ${['test-event']} | ${[{ callback: 'test-event', suggestionId }]} - ${'applyBatch'} | ${[]} | ${[]} + ${'applyBatch'} | ${['test-event']} | ${['test-event']} ${'addToBatch'} | ${[]} | ${[suggestionId]} ${'removeFromBatch'} | ${[]} | ${[suggestionId]} `('emits $event event on sugestion diff header $event', ({ event, childArgs, args }) => { diff --git a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js index ab028ea52b7..1ed7844b395 100644 --- a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js +++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js @@ -1,4 +1,6 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; +// eslint-disable-next-line import/no-deprecated +import { getJSONFixture } from 'helpers/fixtures'; import { trimText } from 'helpers/text_helper'; import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue'; import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; @@ -11,6 +13,7 @@ describe('ProjectListItem component', () => { let vm; let options; + // eslint-disable-next-line import/no-deprecated const project = getJSONFixture('static/projects.json')[0]; beforeEach(() => { diff --git a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js index 06b00a8e196..1f97d3ff3fa 100644 --- a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js +++ b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js @@ -2,6 +2,8 @@ import { GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import { head } from 'lodash'; import Vue from 'vue'; +// eslint-disable-next-line import/no-deprecated +import { getJSONFixture } from 'helpers/fixtures'; import { trimText } from 'helpers/text_helper'; import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue'; @@ -11,6 +13,7 @@ const localVue = createLocalVue(); describe('ProjectSelector component', () => { let wrapper; let vm; + // eslint-disable-next-line import/no-deprecated const allProjects = getJSONFixture('static/projects.json'); const searchResults = allProjects.slice(0, 5); let selected = []; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js index 14e0c8a2278..d9b7cd5afa2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js @@ -157,9 +157,9 @@ describe('LabelsSelect Mutations', () => { beforeEach(() => { labels = [ - { id: 1, title: 'scoped::test', set: true }, - { id: 2, set: false, title: 'scoped::one' }, - { id: 3, title: '' }, + { id: 1, title: 'scoped' }, + { id: 2, title: 'scoped::one', set: false }, + { id: 3, title: 'scoped::test', set: true }, { id: 4, title: '' }, ]; }); @@ -189,9 +189,9 @@ describe('LabelsSelect Mutations', () => { }); expect(state.labels).toEqual([ - { id: 1, title: 'scoped::test', set: false }, - { id: 2, set: true, title: 'scoped::one', touched: true }, - { id: 3, title: '' }, + { id: 1, title: 'scoped' }, + { id: 2, title: 'scoped::one', set: true, touched: true }, + { id: 3, title: 'scoped::test', set: false }, { id: 4, title: '' }, ]); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js index 843298a1406..8931584e12c 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js @@ -5,13 +5,14 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; +import { labelsQueries } from '~/sidebar/constants'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql'; -import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; import { mockSuggestedColors, createLabelSuccessfulResponse, - labelsQueryResponse, + workspaceLabelsQueryResponse, } from './mock_data'; jest.mock('~/flash'); @@ -47,11 +48,14 @@ describe('DropdownContentsCreateView', () => { findAllColors().at(0).vm.$emit('click', new Event('mouseclick')); }; - const createComponent = ({ mutationHandler = createLabelSuccessHandler } = {}) => { + const createComponent = ({ + mutationHandler = createLabelSuccessHandler, + issuableType = IssuableType.Issue, + } = {}) => { const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]); mockApollo.clients.defaultClient.cache.writeQuery({ - query: projectLabelsQuery, - data: labelsQueryResponse.data, + query: labelsQueries[issuableType].workspaceQuery, + data: workspaceLabelsQueryResponse.data, variables: { fullPath: '', searchTerm: '', @@ -61,6 +65,10 @@ describe('DropdownContentsCreateView', () => { wrapper = shallowMount(DropdownContentsCreateView, { localVue, apolloProvider: mockApollo, + propsData: { + issuableType, + fullPath: '', + }, }); }; @@ -135,15 +143,6 @@ describe('DropdownContentsCreateView', () => { expect(findCreateButton().props('disabled')).toBe(false); }); - it('calls a mutation with correct parameters on Create button click', () => { - findCreateButton().vm.$emit('click'); - expect(createLabelSuccessHandler).toHaveBeenCalledWith({ - color: '#009966', - projectPath: '', - title: 'Test title', - }); - }); - it('renders a loader spinner after Create button click', async () => { findCreateButton().vm.$emit('click'); await nextTick(); @@ -162,6 +161,30 @@ describe('DropdownContentsCreateView', () => { }); }); + it('calls a mutation with `projectPath` variable on the issue', () => { + createComponent(); + fillLabelAttributes(); + findCreateButton().vm.$emit('click'); + + expect(createLabelSuccessHandler).toHaveBeenCalledWith({ + color: '#009966', + projectPath: '', + title: 'Test title', + }); + }); + + it('calls a mutation with `groupPath` variable on the epic', () => { + createComponent({ issuableType: IssuableType.Epic }); + fillLabelAttributes(); + findCreateButton().vm.$emit('click'); + + expect(createLabelSuccessHandler).toHaveBeenCalledWith({ + color: '#009966', + groupPath: '', + title: 'Test title', + }); + }); + it('calls createFlash is mutation has a user-recoverable error', async () => { createComponent({ mutationHandler: createLabelUserRecoverableErrorHandler }); fillLabelAttributes(); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js index 537bbc8e71e..fac3331a2b8 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js @@ -1,36 +1,43 @@ -import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { + GlLoadingIcon, + GlSearchBoxByType, + GlDropdownItem, + GlIntersectionObserver, +} from '@gitlab/ui'; 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 { IssuableType } from '~/issue_show/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue'; import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue'; -import { mockConfig, labelsQueryResponse } from './mock_data'; +import { mockConfig, workspaceLabelsQueryResponse } from './mock_data'; jest.mock('~/flash'); const localVue = createLocalVue(); localVue.use(VueApollo); -const selectedLabels = [ +const localSelectedLabels = [ { - id: 28, - title: 'Bug', - description: 'Label for bugs', - color: '#FF0000', - textColor: '#FFFFFF', + color: '#2f7b2e', + description: null, + id: 'gid://gitlab/ProjectLabel/2', + title: 'Label2', }, ]; describe('DropdownContentsLabelsView', () => { let wrapper; - const successfulQueryHandler = jest.fn().mockResolvedValue(labelsQueryResponse); + const successfulQueryHandler = jest.fn().mockResolvedValue(workspaceLabelsQueryResponse); + + const findFirstLabel = () => wrapper.findAllComponents(GlDropdownItem).at(0); const createComponent = ({ initialState = mockConfig, @@ -43,14 +50,13 @@ describe('DropdownContentsLabelsView', () => { localVue, apolloProvider: mockApollo, provide: { - projectPath: 'test', - iid: 1, variant: DropdownVariant.Sidebar, ...injected, }, propsData: { ...initialState, - selectedLabels, + localSelectedLabels, + issuableType: IssuableType.Issue, }, stubs: { GlSearchBoxByType, @@ -65,23 +71,31 @@ describe('DropdownContentsLabelsView', () => { const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType); const findLabels = () => wrapper.findAllComponents(LabelItem); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findObserver = () => wrapper.findComponent(GlIntersectionObserver); const findLabelsList = () => wrapper.find('[data-testid="labels-list"]'); const findNoResultsMessage = () => wrapper.find('[data-testid="no-results"]'); + async function makeObserverAppear() { + await findObserver().vm.$emit('appear'); + } + describe('when loading labels', () => { it('renders disabled search input field', async () => { createComponent(); + await makeObserverAppear(); expect(findSearchInput().props('disabled')).toBe(true); }); it('renders loading icon', async () => { createComponent(); + await makeObserverAppear(); expect(findLoadingIcon().exists()).toBe(true); }); it('does not render labels list', async () => { createComponent(); + await makeObserverAppear(); expect(findLabelsList().exists()).toBe(false); }); }); @@ -89,6 +103,7 @@ describe('DropdownContentsLabelsView', () => { describe('when labels are loaded', () => { beforeEach(async () => { createComponent(); + await makeObserverAppear(); await waitForPromises(); }); @@ -118,6 +133,7 @@ describe('DropdownContentsLabelsView', () => { }, }), }); + await makeObserverAppear(); findSearchInput().vm.$emit('input', '123'); await waitForPromises(); await nextTick(); @@ -127,8 +143,26 @@ describe('DropdownContentsLabelsView', () => { it('calls `createFlash` when fetching labels failed', async () => { createComponent({ queryHandler: jest.fn().mockRejectedValue('Houston, we have a problem!') }); + await makeObserverAppear(); jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); await waitForPromises(); + expect(createFlash).toHaveBeenCalled(); }); + + it('emits an `input` event on label click', async () => { + createComponent(); + await makeObserverAppear(); + await waitForPromises(); + findFirstLabel().trigger('click'); + + expect(wrapper.emitted('input')[0][0]).toEqual(expect.arrayContaining(localSelectedLabels)); + }); + + it('does not trigger query when component did not appear', () => { + createComponent(); + expect(findLoadingIcon().exists()).toBe(false); + expect(findLabelsList().exists()).toBe(false); + expect(successfulQueryHandler).not.toHaveBeenCalled(); + }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js index a1b40a891ec..36704ac5ef3 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js @@ -1,6 +1,5 @@ -import { GlDropdown } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; - +import { nextTick } from 'vue'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; @@ -8,10 +7,26 @@ import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_s import { mockLabels } from './mock_data'; +const showDropdown = jest.fn(); + +const GlDropdownStub = { + template: ` + <div data-testid="dropdown"> + <slot name="header"></slot> + <slot></slot> + <slot name="footer"></slot> + </div> + `, + methods: { + show: showDropdown, + hide: jest.fn(), + }, +}; + describe('DropdownContent', () => { let wrapper; - const createComponent = ({ props = {}, injected = {} } = {}) => { + const createComponent = ({ props = {}, injected = {}, data = {} } = {}) => { wrapper = shallowMount(DropdownContents, { propsData: { labelsCreateTitle: 'test', @@ -22,38 +37,112 @@ describe('DropdownContent', () => { footerManageLabelTitle: 'manage', dropdownButtonText: 'Labels', variant: 'sidebar', + issuableType: 'issue', + fullPath: 'test', ...props, }, + data() { + return { + ...data, + }; + }, provide: { allowLabelCreate: true, labelsManagePath: 'foo/bar', ...injected, }, stubs: { - GlDropdown, + GlDropdown: GlDropdownStub, }, }); }; - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); + const findCreateView = () => wrapper.findComponent(DropdownContentsCreateView); + const findLabelsView = () => wrapper.findComponent(DropdownContentsLabelsView); + const findDropdown = () => wrapper.findComponent(GlDropdownStub); + const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]'); + const findDropdownHeader = () => wrapper.find('[data-testid="dropdown-header"]'); const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]'); const findGoBackButton = () => wrapper.find('[data-testid="go-back-button"]'); + it('calls dropdown `show` method on `isVisible` prop change', async () => { + createComponent(); + await wrapper.setProps({ + isVisible: true, + }); + + expect(findDropdown().emitted('show')).toBeUndefined(); + }); + + it('does not emit `setLabels` event on dropdown hide if labels did not change', () => { + createComponent(); + findDropdown().vm.$emit('hide'); + + expect(wrapper.emitted('setLabels')).toBeUndefined(); + }); + + it('emits `setLabels` event on dropdown hide if labels changed on non-sidebar widget', async () => { + createComponent({ props: { variant: DropdownVariant.Standalone } }); + const updatedLabel = { + id: 28, + title: 'Bug', + description: 'Label for bugs', + color: '#FF0000', + textColor: '#FFFFFF', + }; + findLabelsView().vm.$emit('input', [updatedLabel]); + await nextTick(); + findDropdown().vm.$emit('hide'); + + expect(wrapper.emitted('setLabels')).toEqual([[[updatedLabel]]]); + }); + + it('emits `setLabels` event on visibility change if labels changed on sidebar widget', async () => { + createComponent({ props: { variant: DropdownVariant.Standalone, isVisible: true } }); + const updatedLabel = { + id: 28, + title: 'Bug', + description: 'Label for bugs', + color: '#FF0000', + textColor: '#FFFFFF', + }; + findLabelsView().vm.$emit('input', [updatedLabel]); + wrapper.setProps({ isVisible: false }); + await nextTick(); + + expect(wrapper.emitted('setLabels')).toEqual([[[updatedLabel]]]); + }); + + it('does not render header on standalone variant', () => { + createComponent({ props: { variant: DropdownVariant.Standalone } }); + + expect(findDropdownHeader().exists()).toBe(false); + }); + + it('renders header on embedded variant', () => { + createComponent({ props: { variant: DropdownVariant.Embedded } }); + + expect(findDropdownHeader().exists()).toBe(true); + }); + + it('renders header on sidebar variant', () => { + createComponent(); + + expect(findDropdownHeader().exists()).toBe(true); + }); + describe('Create view', () => { beforeEach(() => { - wrapper.vm.toggleDropdownContentsCreateView(); + createComponent({ data: { showDropdownContentsCreateView: true } }); }); it('renders create view when `showDropdownContentsCreateView` prop is `true`', () => { - expect(wrapper.findComponent(DropdownContentsCreateView).exists()).toBe(true); + expect(findCreateView().exists()).toBe(true); }); it('does not render footer', () => { @@ -67,11 +156,31 @@ describe('DropdownContent', () => { it('renders go back button', () => { expect(findGoBackButton().exists()).toBe(true); }); + + it('changes the view to Labels view on back button click', async () => { + findGoBackButton().vm.$emit('click', new MouseEvent('click')); + await nextTick(); + + expect(findCreateView().exists()).toBe(false); + expect(findLabelsView().exists()).toBe(true); + }); + + it('changes the view to Labels view on `hideCreateView` event', async () => { + findCreateView().vm.$emit('hideCreateView'); + await nextTick(); + + expect(findCreateView().exists()).toBe(false); + expect(findLabelsView().exists()).toBe(true); + }); }); describe('Labels view', () => { + beforeEach(() => { + createComponent(); + }); + it('renders labels view when `showDropdownContentsCreateView` when `showDropdownContentsCreateView` prop is `false`', () => { - expect(wrapper.findComponent(DropdownContentsLabelsView).exists()).toBe(true); + expect(findLabelsView().exists()).toBe(true); }); it('renders footer on sidebar dropdown', () => { @@ -109,19 +218,12 @@ describe('DropdownContent', () => { expect(findCreateLabelButton().exists()).toBe(true); }); - it('triggers `toggleDropdownContent` method on create label button click', () => { - jest.spyOn(wrapper.vm, 'toggleDropdownContent').mockImplementation(() => {}); + it('changes the view to Create on create label button click', async () => { findCreateLabelButton().trigger('click'); - expect(wrapper.vm.toggleDropdownContent).toHaveBeenCalled(); + await nextTick(); + expect(findLabelsView().exists()).toBe(false); }); }); }); - - describe('template', () => { - it('renders component container element with classes `gl-w-full gl-mt-2` and no styles', () => { - expect(wrapper.attributes('class')).toContain('gl-w-full gl-mt-2'); - expect(wrapper.attributes('style')).toBeUndefined(); - }); - }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js index a18511fa21d..b5441d711a5 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js @@ -1,28 +1,55 @@ -import { shallowMount } from '@vue/test-utils'; +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 { IssuableType } from '~/issue_show/constants'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue'; -import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue'; +import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql'; import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; +import { mockConfig, issuableLabelsQueryResponse } from './mock_data'; -import { mockConfig } from './mock_data'; +jest.mock('~/flash'); + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +const successfulQueryHandler = jest.fn().mockResolvedValue(issuableLabelsQueryResponse); +const errorQueryHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); describe('LabelsSelectRoot', () => { let wrapper; - const createComponent = (config = mockConfig, slots = {}) => { + const findSidebarEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findDropdownValue = () => wrapper.findComponent(DropdownValue); + const findDropdownContents = () => wrapper.findComponent(DropdownContents); + + const createComponent = ({ + config = mockConfig, + slots = {}, + queryHandler = successfulQueryHandler, + } = {}) => { + const mockApollo = createMockApollo([[issueLabelsQuery, queryHandler]]); + wrapper = shallowMount(LabelsSelectRoot, { slots, - propsData: config, + apolloProvider: mockApollo, + localVue, + propsData: { + ...config, + issuableType: IssuableType.Issue, + }, stubs: { - DropdownContents, SidebarEditableItem, }, provide: { - iid: '1', - projectPath: 'test', canUpdate: true, allowLabelEdit: true, + allowLabelCreate: true, + labelsManagePath: 'test', }, }); }; @@ -42,33 +69,63 @@ describe('LabelsSelectRoot', () => { ${'embedded'} | ${'is-embedded'} `( 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"', - ({ variant, cssClass }) => { + async ({ variant, cssClass }) => { createComponent({ - ...mockConfig, - variant, + config: { ...mockConfig, variant }, }); - return wrapper.vm.$nextTick(() => { - expect(wrapper.classes()).toContain(cssClass); - }); + await nextTick(); + expect(wrapper.classes()).toContain(cssClass); }, ); - it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => { - createComponent(); - await wrapper.vm.$nextTick; - expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); - }); + describe('if dropdown variant is `sidebar`', () => { + it('renders sidebar editable item', () => { + createComponent(); + expect(findSidebarEditableItem().exists()).toBe(true); + }); + + it('passes true `loading` prop to sidebar editable item when loading labels', () => { + createComponent(); + expect(findSidebarEditableItem().props('loading')).toBe(true); + }); - it('renders `dropdown-value` component', async () => { - createComponent(mockConfig, { - default: 'None', + describe('when labels are fetched successfully', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('passes true `loading` prop to sidebar editable item', () => { + expect(findSidebarEditableItem().props('loading')).toBe(false); + }); + + it('renders dropdown value component when query labels is resolved', () => { + expect(findDropdownValue().exists()).toBe(true); + expect(findDropdownValue().props('selectedLabels')).toEqual( + issuableLabelsQueryResponse.data.workspace.issuable.labels.nodes, + ); + }); + + it('emits `onLabelRemove` event on dropdown value label remove event', () => { + const label = { id: 'gid://gitlab/ProjectLabel/1' }; + findDropdownValue().vm.$emit('onLabelRemove', label); + expect(wrapper.emitted('onLabelRemove')).toEqual([[label]]); + }); }); - await wrapper.vm.$nextTick; - const valueComp = wrapper.find(DropdownValue); + it('creates flash with error message when query is rejected', async () => { + createComponent({ queryHandler: errorQueryHandler }); + await waitForPromises(); + expect(createFlash).toHaveBeenCalledWith({ message: 'Error fetching labels.' }); + }); + }); + + it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event', async () => { + const label = { id: 'gid://gitlab/ProjectLabel/1' }; + createComponent(); - expect(valueComp.exists()).toBe(true); - expect(valueComp.text()).toBe('None'); + findDropdownContents().vm.$emit('setLabels', [label]); + expect(wrapper.emitted('updateSelectedLabels')).toEqual([[[label]]]); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js index fceaabec2d0..23a457848d9 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js @@ -34,6 +34,8 @@ export const mockLabels = [ ]; export const mockConfig = { + iid: '1', + fullPath: 'test', allowMultiselect: true, labelsListTitle: 'Assign labels', labelsCreateTitle: 'Create label', @@ -86,7 +88,7 @@ export const createLabelSuccessfulResponse = { }, }; -export const labelsQueryResponse = { +export const workspaceLabelsQueryResponse = { data: { workspace: { labels: { @@ -108,3 +110,23 @@ export const labelsQueryResponse = { }, }, }; + +export const issuableLabelsQueryResponse = { + data: { + workspace: { + issuable: { + id: '1', + labels: { + nodes: [ + { + color: '#330066', + description: null, + id: 'gid://gitlab/ProjectLabel/1', + title: 'Label1', + }, + ], + }, + }, + }, + }, +}; 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 af4fa462cbf..0f1e118d44c 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 @@ -45,6 +45,7 @@ exports[`Upload dropzone component correctly overrides description and drop mess > <div class="mw-50 gl-text-center" + style="display: none;" > <h3 class="" @@ -61,7 +62,6 @@ exports[`Upload dropzone component correctly overrides description and drop mess <div class="mw-50 gl-text-center" - style="display: none;" > <h3 class="" @@ -146,7 +146,6 @@ exports[`Upload dropzone component when dragging renders correct template when d <div class="mw-50 gl-text-center" - style="" > <h3 class="" @@ -231,7 +230,6 @@ exports[`Upload dropzone component when dragging renders correct template when d <div class="mw-50 gl-text-center" - style="" > <h3 class="" @@ -299,6 +297,7 @@ exports[`Upload dropzone component when dragging renders correct template when d > <div class="mw-50 gl-text-center" + style="" > <h3 class="" @@ -383,6 +382,7 @@ exports[`Upload dropzone component when dragging renders correct template when d > <div class="mw-50 gl-text-center" + style="" > <h3 class="" @@ -467,6 +467,7 @@ exports[`Upload dropzone component when dragging renders correct template when d > <div class="mw-50 gl-text-center" + style="" > <h3 class="" @@ -551,6 +552,7 @@ exports[`Upload dropzone component when no slot provided renders default dropzon > <div class="mw-50 gl-text-center" + style="display: none;" > <h3 class="" @@ -567,7 +569,6 @@ exports[`Upload dropzone component when no slot provided renders default dropzon <div class="mw-50 gl-text-center" - style="display: none;" > <h3 class="" @@ -603,6 +604,7 @@ exports[`Upload dropzone component when slot provided renders dropzone with slot > <div class="mw-50 gl-text-center" + style="display: none;" > <h3 class="" @@ -619,7 +621,6 @@ exports[`Upload dropzone component when slot provided renders dropzone with slot <div class="mw-50 gl-text-center" - style="display: none;" > <h3 class="" diff --git a/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js b/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js new file mode 100644 index 00000000000..a92f058f311 --- /dev/null +++ b/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js @@ -0,0 +1,116 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; + +const mockSchedules = [ + { + type: OBSTACLE_TYPES.oncallSchedules, + name: 'Schedule 1', + url: 'http://gitlab.com/gitlab-org/gitlab-shell/-/oncall_schedules', + projectName: 'Shell', + projectUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/', + }, + { + type: OBSTACLE_TYPES.oncallSchedules, + name: 'Schedule 2', + url: 'http://gitlab.com/gitlab-org/gitlab-ui/-/oncall_schedules', + projectName: 'UI', + projectUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/', + }, +]; +const mockPolicies = [ + { + type: OBSTACLE_TYPES.escalationPolicies, + name: 'Policy 1', + url: 'http://gitlab.com/gitlab-org/gitlab-ui/-/escalation-policies', + projectName: 'UI', + projectUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/', + }, +]; +const mockObstacles = mockSchedules.concat(mockPolicies); + +const userName = "O'User"; + +describe('User deletion obstacles list', () => { + let wrapper; + + function createComponent(props) { + wrapper = extendedWrapper( + shallowMount(UserDeletionObstaclesList, { + propsData: { + obstacles: mockObstacles, + userName, + ...props, + }, + stubs: { + GlSprintf, + }, + }), + ); + } + + afterEach(() => { + wrapper.destroy(); + }); + + const findLinks = () => wrapper.findAllComponents(GlLink); + const findTitle = () => wrapper.findByTestId('title'); + const findFooter = () => wrapper.findByTestId('footer'); + const findObstacles = () => wrapper.findByTestId('obstacles-list'); + + describe.each` + isCurrentUser | titleText | footerText + ${true} | ${'You are currently a part of:'} | ${'Removing yourself may put your on-call team at risk of missing a notification.'} + ${false} | ${`User ${userName} is currently part of:`} | ${'Removing this user may put their on-call team at risk of missing a notification.'} + `('when current user', ({ isCurrentUser, titleText, footerText }) => { + it(`${isCurrentUser ? 'is' : 'is not'} a part of on-call management`, async () => { + createComponent({ + isCurrentUser, + }); + + expect(findTitle().text()).toBe(titleText); + expect(findFooter().text()).toBe(footerText); + }); + }); + + describe.each(mockObstacles)( + 'renders all obstacles', + ({ type, name, url, projectName, projectUrl }) => { + it(`includes the project name and link for ${name}`, () => { + createComponent({ obstacles: [{ type, name, url, projectName, projectUrl }] }); + const msg = findObstacles().text(); + + expect(msg).toContain(`in Project ${projectName}`); + expect(findLinks().at(1).attributes('href')).toBe(projectUrl); + }); + }, + ); + + describe.each(mockSchedules)( + 'renders on-call schedules', + ({ type, name, url, projectName, projectUrl }) => { + it(`includes the schedule name and link for ${name}`, () => { + createComponent({ obstacles: [{ type, name, url, projectName, projectUrl }] }); + const msg = findObstacles().text(); + + expect(msg).toContain(`On-call schedule ${name}`); + expect(findLinks().at(0).attributes('href')).toBe(url); + }); + }, + ); + + describe.each(mockPolicies)( + 'renders escalation policies', + ({ type, name, url, projectName, projectUrl }) => { + it(`includes the policy name and link for ${name}`, () => { + createComponent({ obstacles: [{ type, name, url, projectName, projectUrl }] }); + const msg = findObstacles().text(); + + expect(msg).toContain(`Escalation policy ${name}`); + expect(findLinks().at(0).attributes('href')).toBe(url); + }); + }, + ); +}); diff --git a/spec/frontend/vue_shared/components/user_deletion_obstacles/utils_spec.js b/spec/frontend/vue_shared/components/user_deletion_obstacles/utils_spec.js new file mode 100644 index 00000000000..99f739098f7 --- /dev/null +++ b/spec/frontend/vue_shared/components/user_deletion_obstacles/utils_spec.js @@ -0,0 +1,43 @@ +import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; + +describe('parseUserDeletionObstacles', () => { + const mockObstacles = [{ name: 'Obstacle' }]; + const expectedSchedule = { name: 'Obstacle', type: OBSTACLE_TYPES.oncallSchedules }; + const expectedPolicy = { name: 'Obstacle', type: OBSTACLE_TYPES.escalationPolicies }; + + it('is undefined when user is not available', () => { + expect(parseUserDeletionObstacles()).toHaveLength(0); + }); + + it('is empty when obstacles are not available for user', () => { + expect(parseUserDeletionObstacles({})).toHaveLength(0); + }); + + it('is empty when user has no obstacles to deletion', () => { + const input = { oncallSchedules: [], escalationPolicies: [] }; + + expect(parseUserDeletionObstacles(input)).toHaveLength(0); + }); + + it('returns obstacles with type when user is part of on-call schedules', () => { + const input = { oncallSchedules: mockObstacles, escalationPolicies: [] }; + const expectedOutput = [expectedSchedule]; + + expect(parseUserDeletionObstacles(input)).toEqual(expectedOutput); + }); + + it('returns obstacles with type when user is part of escalation policies', () => { + const input = { oncallSchedules: [], escalationPolicies: mockObstacles }; + const expectedOutput = [expectedPolicy]; + + expect(parseUserDeletionObstacles(input)).toEqual(expectedOutput); + }); + + it('returns obstacles with type when user have every obstacle type', () => { + const input = { oncallSchedules: mockObstacles, escalationPolicies: mockObstacles }; + const expectedOutput = [expectedSchedule, expectedPolicy]; + + expect(parseUserDeletionObstacles(input)).toEqual(expectedOutput); + }); +}); 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 926223e0670..09633daf587 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', + localTime: '2:30 PM', bot: false, bio: null, workInformation: null, @@ -31,10 +32,11 @@ describe('User Popover Component', () => { wrapper.destroy(); }); - const findUserStatus = () => wrapper.find('.js-user-status'); + const findUserStatus = () => wrapper.findByTestId('user-popover-status'); const findTarget = () => document.querySelector('.js-user-link'); const findUserName = () => wrapper.find(UserNameWithStatus); const findSecurityBotDocsLink = () => wrapper.findByTestId('user-popover-bot-docs-link'); + const findUserLocalTime = () => wrapper.findByTestId('user-popover-local-time'); const createWrapper = (props = {}, options = {}) => { wrapper = mountExtended(UserPopover, { @@ -71,7 +73,6 @@ describe('User Popover Component', () => { expect(wrapper.text()).toContain(DEFAULT_PROPS.user.name); expect(wrapper.text()).toContain(DEFAULT_PROPS.user.username); - expect(wrapper.text()).toContain(DEFAULT_PROPS.user.location); }); it('shows icon for location', () => { @@ -164,6 +165,25 @@ describe('User Popover Component', () => { }); }); + describe('local time', () => { + it('should show local time when it is available', () => { + createWrapper(); + + expect(findUserLocalTime().exists()).toBe(true); + }); + + it('should not show local time when it is not available', () => { + const user = { + ...DEFAULT_PROPS.user, + localTime: null, + }; + + createWrapper({ user }); + + expect(findUserLocalTime().exists()).toBe(false); + }); + }); + describe('status data', () => { it('should show only message', () => { const user = { ...DEFAULT_PROPS.user, status: { message_html: 'Hello World' } }; @@ -256,5 +276,11 @@ describe('User Popover Component', () => { const securityBotDocsLink = findSecurityBotDocsLink(); expect(securityBotDocsLink.text()).toBe('Learn more about %<>\';"'); }); + + it('does not display local time', () => { + createWrapper({ user: SECURITY_BOT_USER }); + + expect(findUserLocalTime().exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index 5fe4eeb6061..92938b2717f 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -160,4 +160,26 @@ describe('Web IDE link component', () => { expect(findLocalStorageSync().props('value')).toBe(ACTION_GITPOD.key); }); }); + + describe('edit actions', () => { + it.each([ + { + props: { showWebIdeButton: true, showEditButton: false }, + expectedEventPayload: 'ide', + }, + { + props: { showWebIdeButton: false, showEditButton: true }, + expectedEventPayload: 'simple', + }, + ])( + 'emits the correct event when an action handler is called', + async ({ props, expectedEventPayload }) => { + createComponent({ ...props, needsToFork: true }); + + findActionsButton().props('actions')[0].handle(); + + expect(wrapper.emitted('edit')).toEqual([[expectedEventPayload]]); + }, + ); + }); }); diff --git a/spec/frontend/vue_shared/directives/validation_spec.js b/spec/frontend/vue_shared/directives/validation_spec.js index 51ee73cabde..dcd3a44a6fc 100644 --- a/spec/frontend/vue_shared/directives/validation_spec.js +++ b/spec/frontend/vue_shared/directives/validation_spec.js @@ -4,11 +4,13 @@ import validation, { initForm } from '~/vue_shared/directives/validation'; describe('validation directive', () => { let wrapper; - const createComponentFactory = ({ inputAttributes, template, data }) => { - const defaultInputAttributes = { - type: 'text', - required: true, - }; + const createComponentFactory = (options) => { + const { + inputAttributes = { type: 'text', required: true }, + template, + data, + feedbackMap = {}, + } = options; const defaultTemplate = ` <form> @@ -18,11 +20,11 @@ describe('validation directive', () => { const component = { directives: { - validation: validation(), + validation: validation(feedbackMap), }, data() { return { - attributes: inputAttributes || defaultInputAttributes, + attributes: inputAttributes, ...data, }; }, @@ -32,8 +34,10 @@ describe('validation directive', () => { wrapper = shallowMount(component, { attachTo: document.body }); }; - const createComponent = ({ inputAttributes, showValidation, template } = {}) => - createComponentFactory({ + const createComponent = (options = {}) => { + const { inputAttributes, showValidation, template, feedbackMap } = options; + + return createComponentFactory({ inputAttributes, data: { showValidation, @@ -48,10 +52,14 @@ describe('validation directive', () => { }, }, template, + feedbackMap, }); + }; + + const createComponentWithInitForm = (options = {}) => { + const { inputAttributes, feedbackMap } = options; - const createComponentWithInitForm = ({ inputAttributes } = {}) => - createComponentFactory({ + return createComponentFactory({ inputAttributes, data: { form: initForm({ @@ -68,7 +76,9 @@ describe('validation directive', () => { <input v-validation:[form.showValidation] name="exampleField" v-bind="attributes" /> </form> `, + feedbackMap, }); + }; afterEach(() => { wrapper.destroy(); @@ -209,6 +219,111 @@ describe('validation directive', () => { }); }); + describe('with custom feedbackMap', () => { + const customMessage = 'Please fill out the name field.'; + const template = ` + <form> + <div v-validation:[showValidation]> + <input name="exampleField" v-bind="attributes" /> + </div> + </form> + `; + beforeEach(() => { + const feedbackMap = { + valueMissing: { + isInvalid: (el) => el.validity?.valueMissing, + message: customMessage, + }, + }; + + createComponent({ + template, + inputAttributes: { + required: true, + }, + feedbackMap, + }); + }); + + describe('with invalid value', () => { + beforeEach(() => { + setValueAndTriggerValidation(''); + }); + + it('should set correct field state', () => { + expect(getFormData().fields.exampleField).toEqual({ + state: false, + feedback: customMessage, + }); + }); + }); + + describe('with valid value', () => { + beforeEach(() => { + setValueAndTriggerValidation('hello'); + }); + + it('set the correct state', () => { + expect(getFormData().fields.exampleField).toEqual({ + state: true, + feedback: '', + }); + }); + }); + }); + + describe('with validation-message present on the element', () => { + const customMessage = 'The name field is required.'; + const template = ` + <form> + <div v-validation:[showValidation]> + <input name="exampleField" v-bind="attributes" validation-message="${customMessage}" /> + </div> + </form> + `; + beforeEach(() => { + const feedbackMap = { + valueMissing: { + isInvalid: (el) => el.validity?.valueMissing, + }, + }; + + createComponent({ + template, + inputAttributes: { + required: true, + }, + feedbackMap, + }); + }); + + describe('with invalid value', () => { + beforeEach(() => { + setValueAndTriggerValidation(''); + }); + + it('should set correct field state', () => { + expect(getFormData().fields.exampleField).toEqual({ + state: false, + feedback: customMessage, + }); + }); + }); + + describe('with valid value', () => { + beforeEach(() => { + setValueAndTriggerValidation('hello'); + }); + + it('set the correct state', () => { + expect(getFormData().fields.exampleField).toEqual({ + state: true, + feedback: '', + }); + }); + }); + }); + describe('component using initForm', () => { it('sets the form fields correctly', () => { createComponentWithInitForm(); diff --git a/spec/frontend/vue_shared/oncall_schedules_list_spec.js b/spec/frontend/vue_shared/oncall_schedules_list_spec.js deleted file mode 100644 index f83a5187b8b..00000000000 --- a/spec/frontend/vue_shared/oncall_schedules_list_spec.js +++ /dev/null @@ -1,87 +0,0 @@ -import { GlLink, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; - -const mockSchedules = [ - { - name: 'Schedule 1', - scheduleUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/-/oncall_schedules', - projectName: 'Shell', - projectUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/', - }, - { - name: 'Schedule 2', - scheduleUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/-/oncall_schedules', - projectName: 'UI', - projectUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/', - }, -]; - -const userName = "O'User"; - -describe('On-call schedules list', () => { - let wrapper; - - function createComponent(props) { - wrapper = extendedWrapper( - shallowMount(OncallSchedulesList, { - propsData: { - schedules: mockSchedules, - userName, - ...props, - }, - stubs: { - GlSprintf, - }, - }), - ); - } - - afterEach(() => { - wrapper.destroy(); - }); - - const findLinks = () => wrapper.findAllComponents(GlLink); - const findTitle = () => wrapper.findByTestId('title'); - const findFooter = () => wrapper.findByTestId('footer'); - const findSchedules = () => wrapper.findByTestId('schedules-list'); - - describe.each` - isCurrentUser | titleText | footerText - ${true} | ${'You are currently a part of:'} | ${'Removing yourself may put your on-call team at risk of missing a notification.'} - ${false} | ${`User ${userName} is currently part of:`} | ${'Removing this user may put their on-call team at risk of missing a notification.'} - `('when current user ', ({ isCurrentUser, titleText, footerText }) => { - it(`${isCurrentUser ? 'is' : 'is not'} a part of on-call schedule`, async () => { - createComponent({ - isCurrentUser, - }); - - expect(findTitle().text()).toBe(titleText); - expect(findFooter().text()).toBe(footerText); - }); - }); - - describe.each(mockSchedules)( - 'renders each on-call schedule data', - ({ name, scheduleUrl, projectName, projectUrl }) => { - beforeEach(() => { - createComponent({ schedules: [{ name, scheduleUrl, projectName, projectUrl }] }); - }); - - it(`renders schedule ${name}'s name and link`, () => { - const msg = findSchedules().text(); - - expect(msg).toContain(`On-call schedule ${name}`); - expect(findLinks().at(0).attributes('href')).toBe(scheduleUrl); - }); - - it(`renders project ${projectName}'s name and link`, () => { - const msg = findSchedules().text(); - - expect(msg).toContain(`in Project ${projectName}`); - expect(findLinks().at(1).attributes('href')).toBe(projectUrl); - }); - }, - ); -}); diff --git a/spec/frontend/vue_shared/security_reports/mock_data.js b/spec/frontend/vue_shared/security_reports/mock_data.js index 06631710509..cdaeec78e47 100644 --- a/spec/frontend/vue_shared/security_reports/mock_data.js +++ b/spec/frontend/vue_shared/security_reports/mock_data.js @@ -314,7 +314,7 @@ export const sastDiffSuccessMock = { head_report_created_at: '2020-01-10T10:00:00.000Z', }; -export const secretScanningDiffSuccessMock = { +export const secretDetectionDiffSuccessMock = { added: [mockFindings[0], mockFindings[1]], fixed: [mockFindings[2]], base_report_created_at: '2020-01-01T10:00:00.000Z', diff --git a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js index 4d579fa61df..68a97103d3a 100644 --- a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js +++ b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js @@ -12,7 +12,7 @@ import { securityReportMergeRequestDownloadPathsQueryNoArtifactsResponse, securityReportMergeRequestDownloadPathsQueryResponse, sastDiffSuccessMock, - secretScanningDiffSuccessMock, + secretDetectionDiffSuccessMock, } from 'jest/vue_shared/security_reports/mock_data'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; @@ -31,7 +31,7 @@ Vue.use(VueApollo); Vue.use(Vuex); const SAST_COMPARISON_PATH = '/sast.json'; -const SECRET_SCANNING_COMPARISON_PATH = '/secret_detection.json'; +const SECRET_DETECTION_COMPARISON_PATH = '/secret_detection.json'; describe('Security reports app', () => { let wrapper; @@ -175,12 +175,12 @@ describe('Security reports app', () => { const SAST_SUCCESS_MESSAGE = 'Security scanning detected 1 potential vulnerability 1 Critical 0 High and 0 Others'; - const SECRET_SCANNING_SUCCESS_MESSAGE = + const SECRET_DETECTION_SUCCESS_MESSAGE = 'Security scanning detected 2 potential vulnerabilities 1 Critical 1 High and 0 Others'; describe.each` - reportType | pathProp | path | successResponse | successMessage - ${REPORT_TYPE_SAST} | ${'sastComparisonPath'} | ${SAST_COMPARISON_PATH} | ${sastDiffSuccessMock} | ${SAST_SUCCESS_MESSAGE} - ${REPORT_TYPE_SECRET_DETECTION} | ${'secretScanningComparisonPath'} | ${SECRET_SCANNING_COMPARISON_PATH} | ${secretScanningDiffSuccessMock} | ${SECRET_SCANNING_SUCCESS_MESSAGE} + reportType | pathProp | path | successResponse | successMessage + ${REPORT_TYPE_SAST} | ${'sastComparisonPath'} | ${SAST_COMPARISON_PATH} | ${sastDiffSuccessMock} | ${SAST_SUCCESS_MESSAGE} + ${REPORT_TYPE_SECRET_DETECTION} | ${'secretDetectionComparisonPath'} | ${SECRET_DETECTION_COMPARISON_PATH} | ${secretDetectionDiffSuccessMock} | ${SECRET_DETECTION_SUCCESS_MESSAGE} `( 'given a $pathProp and $reportType artifact', ({ pathProp, path, successResponse, successMessage }) => { diff --git a/spec/frontend/vue_shared/security_reports/store/getters_spec.js b/spec/frontend/vue_shared/security_reports/store/getters_spec.js index 97746c7c38b..bcc8955ba02 100644 --- a/spec/frontend/vue_shared/security_reports/store/getters_spec.js +++ b/spec/frontend/vue_shared/security_reports/store/getters_spec.js @@ -8,7 +8,7 @@ import { summaryCounts, } from '~/vue_shared/security_reports/store/getters'; import createSastState from '~/vue_shared/security_reports/store/modules/sast/state'; -import createSecretScanningState from '~/vue_shared/security_reports/store/modules/secret_detection/state'; +import createSecretDetectionState from '~/vue_shared/security_reports/store/modules/secret_detection/state'; import createState from '~/vue_shared/security_reports/store/state'; import { groupedTextBuilder } from '~/vue_shared/security_reports/store/utils'; import { CRITICAL, HIGH, LOW } from '~/vulnerabilities/constants'; @@ -21,7 +21,7 @@ describe('Security reports getters', () => { beforeEach(() => { state = createState(); state.sast = createSastState(); - state.secretDetection = createSecretScanningState(); + state.secretDetection = createSecretDetectionState(); }); describe('summaryCounts', () => { diff --git a/spec/frontend_integration/fixture_generators.yml b/spec/frontend_integration/fixture_generators.yml new file mode 100644 index 00000000000..1f6ff85352d --- /dev/null +++ b/spec/frontend_integration/fixture_generators.yml @@ -0,0 +1,5 @@ +- spec/frontend/fixtures/api_projects.rb +- spec/frontend/fixtures/api_merge_requests.rb +- spec/frontend/fixtures/projects_json.rb +- spec/frontend/fixtures/merge_requests_diffs.rb +- spec/frontend/fixtures/raw.rb diff --git a/spec/frontend_integration/test_helpers/mock_server/graphql.js b/spec/frontend_integration/test_helpers/mock_server/graphql.js index 27396842523..d4ee7c02839 100644 --- a/spec/frontend_integration/test_helpers/mock_server/graphql.js +++ b/spec/frontend_integration/test_helpers/mock_server/graphql.js @@ -1,9 +1,7 @@ import { buildSchema, graphql } from 'graphql'; import { memoize } from 'lodash'; -// The graphql schema is dynamically generated in CI -// during the `graphql-schema-dump` job. -// eslint-disable-next-line global-require, import/no-unresolved +// eslint-disable-next-line global-require const getGraphqlSchema = () => require('../../../../tmp/tests/graphql/gitlab_schema.graphql'); const graphqlResolvers = { diff --git a/spec/graphql/mutations/clusters/agent_tokens/create_spec.rb b/spec/graphql/mutations/clusters/agent_tokens/create_spec.rb new file mode 100644 index 00000000000..fc025c8e3d3 --- /dev/null +++ b/spec/graphql/mutations/clusters/agent_tokens/create_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Clusters::AgentTokens::Create do + subject(:mutation) { described_class.new(object: nil, context: context, field: nil) } + + let_it_be(:cluster_agent) { create(:cluster_agent) } + let_it_be(:user) { create(:user) } + + let(:context) do + GraphQL::Query::Context.new( + query: OpenStruct.new(schema: nil), + values: { current_user: user }, + object: nil + ) + end + + specify { expect(described_class).to require_graphql_authorizations(:create_cluster) } + + describe '#resolve' do + let(:description) { 'new token!' } + let(:name) { 'new name' } + + subject { mutation.resolve(cluster_agent_id: cluster_agent.to_global_id, description: description, name: name) } + + context 'without token permissions' do + it 'raises an error if the resource is not accessible to the user' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'with user permissions' do + before do + cluster_agent.project.add_maintainer(user) + end + + it 'creates a new token', :aggregate_failures do + expect { subject }.to change { ::Clusters::AgentToken.count }.by(1) + expect(subject[:errors]).to eq([]) + end + + it 'returns token information', :aggregate_failures do + token = subject[:token] + + expect(subject[:secret]).not_to be_nil + expect(token.created_by_user).to eq(user) + expect(token.description).to eq(description) + expect(token.name).to eq(name) + end + + context 'invalid params' do + subject { mutation.resolve(cluster_agent_id: cluster_agent.id) } + + it 'generates an error message when id invalid', :aggregate_failures do + expect { subject }.to raise_error(::GraphQL::CoercionError) + end + end + end + end +end diff --git a/spec/graphql/mutations/clusters/agent_tokens/delete_spec.rb b/spec/graphql/mutations/clusters/agent_tokens/delete_spec.rb new file mode 100644 index 00000000000..5cdbc0f6d72 --- /dev/null +++ b/spec/graphql/mutations/clusters/agent_tokens/delete_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Clusters::AgentTokens::Delete do + let(:token) { create(:cluster_agent_token) } + let(:user) { create(:user) } + + let(:mutation) do + described_class.new( + object: double, + context: { current_user: user }, + field: double + ) + end + + it { expect(described_class.graphql_name).to eq('ClusterAgentTokenDelete') } + it { expect(described_class).to require_graphql_authorizations(:admin_cluster) } + + describe '#resolve' do + let(:global_id) { token.to_global_id } + + subject { mutation.resolve(id: global_id) } + + context 'without user permissions' do + it 'fails to delete the cluster agent', :aggregate_failures do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + expect { token.reload }.not_to raise_error + end + end + + context 'with user permissions' do + before do + token.agent.project.add_maintainer(user) + end + + it 'deletes a cluster agent', :aggregate_failures do + expect { subject }.to change { ::Clusters::AgentToken.count }.by(-1) + expect { token.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'with invalid params' do + let(:global_id) { token.id } + + it 'raises an error if the cluster agent id is invalid', :aggregate_failures do + expect { subject }.to raise_error(::GraphQL::CoercionError) + expect { token.reload }.not_to raise_error + end + end + end +end diff --git a/spec/graphql/mutations/clusters/agents/create_spec.rb b/spec/graphql/mutations/clusters/agents/create_spec.rb new file mode 100644 index 00000000000..c80b6f6cdad --- /dev/null +++ b/spec/graphql/mutations/clusters/agents/create_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Clusters::Agents::Create do + subject(:mutation) { described_class.new(object: nil, context: context, field: nil) } + + let(:project) { create(:project, :public, :repository) } + let(:user) { create(:user) } + let(:context) do + GraphQL::Query::Context.new( + query: OpenStruct.new(schema: nil), + values: { current_user: user }, + object: nil + ) + end + + specify { expect(described_class).to require_graphql_authorizations(:create_cluster) } + + describe '#resolve' do + subject { mutation.resolve(project_path: project.full_path, name: 'test-agent') } + + context 'without project permissions' do + it 'raises an error if the resource is not accessible to the user' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'with user permissions' do + before do + project.add_maintainer(user) + end + + it 'creates a new clusters_agent', :aggregate_failures do + expect { subject }.to change { ::Clusters::Agent.count }.by(1) + expect(subject[:cluster_agent].name).to eq('test-agent') + expect(subject[:errors]).to eq([]) + end + + context 'invalid params' do + subject { mutation.resolve(project_path: project.full_path, name: '@bad_name!') } + + it 'generates an error message when name is invalid', :aggregate_failures do + expect(subject[:clusters_agent]).to be_nil + expect(subject[:errors]).to eq(["Name can contain only lowercase letters, digits, and '-', but cannot start or end with '-'"]) + end + end + end + end +end diff --git a/spec/graphql/mutations/clusters/agents/delete_spec.rb b/spec/graphql/mutations/clusters/agents/delete_spec.rb new file mode 100644 index 00000000000..0aabf53391a --- /dev/null +++ b/spec/graphql/mutations/clusters/agents/delete_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Clusters::Agents::Delete do + subject(:mutation) { described_class.new(object: nil, context: context, field: nil) } + + let(:cluster_agent) { create(:cluster_agent) } + let(:project) { cluster_agent.project } + let(:user) { create(:user) } + let(:context) do + GraphQL::Query::Context.new( + query: OpenStruct.new(schema: nil), + values: { current_user: user }, + object: nil + ) + end + + specify { expect(described_class).to require_graphql_authorizations(:admin_cluster) } + + describe '#resolve' do + subject { mutation.resolve(id: cluster_agent.to_global_id) } + + context 'without user permissions' do + it 'fails to delete the cluster agent', :aggregate_failures do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + expect { cluster_agent.reload }.not_to raise_error + end + end + + context 'with user permissions' do + before do + project.add_maintainer(user) + end + + it 'deletes a cluster agent', :aggregate_failures do + expect { subject }.to change { ::Clusters::Agent.count }.by(-1) + expect { cluster_agent.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'with invalid params' do + subject { mutation.resolve(id: cluster_agent.id) } + + it 'raises an error if the cluster agent id is invalid', :aggregate_failures do + expect { subject }.to raise_error(::GraphQL::CoercionError) + expect { cluster_agent.reload }.not_to raise_error + end + end + end +end diff --git a/spec/graphql/mutations/customer_relations/contacts/create_spec.rb b/spec/graphql/mutations/customer_relations/contacts/create_spec.rb new file mode 100644 index 00000000000..21a1aa2741a --- /dev/null +++ b/spec/graphql/mutations/customer_relations/contacts/create_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::CustomerRelations::Contacts::Create do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + + let(:not_found_or_does_not_belong) { 'The specified organization was not found or does not belong to this group' } + let(:valid_params) do + attributes_for(:contact, + group: group, + description: 'Managing Director' + ) + end + + describe '#resolve' do + subject(:resolve_mutation) do + described_class.new(object: nil, context: { current_user: user }, field: nil).resolve( + **valid_params, + group_id: group.to_global_id + ) + end + + context 'when the user does not have permission' do + before do + group.add_reporter(user) + end + + it 'raises an error' do + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + .with_message("The resource that you are attempting to access does not exist or you don't have permission to perform this action") + end + end + + context 'when the user has permission' do + before_all do + group.add_developer(user) + end + + context 'when the feature is disabled' do + before do + stub_feature_flags(customer_relations: false) + end + + it 'raises an error' do + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + .with_message('Feature disabled') + end + end + + context 'when the params are invalid' do + it 'returns the validation error' do + valid_params[:first_name] = nil + + expect(resolve_mutation[:errors]).to match_array(["First name can't be blank"]) + end + end + + context 'when attaching to an organization' do + context 'when all ok' do + before do + organization = create(:organization, group: group) + valid_params[:organization_id] = organization.to_global_id + end + + it 'creates contact with correct values' do + expect(resolve_mutation[:contact].organization).to be_present + end + end + + context 'when organization_id is invalid' do + before do + valid_params[:organization_id] = "gid://gitlab/CustomerRelations::Organization/#{non_existing_record_id}" + end + + it 'returns the relevant error' do + expect(resolve_mutation[:errors]).to match_array([not_found_or_does_not_belong]) + end + end + + context 'when organzation belongs to a different group' do + before do + organization = create(:organization) + valid_params[:organization_id] = organization.to_global_id + end + + it 'returns the relevant error' do + expect(resolve_mutation[:errors]).to match_array([not_found_or_does_not_belong]) + end + end + end + + it 'creates contact with correct values' do + expect(resolve_mutation[:contact]).to have_attributes(valid_params) + end + end + end + + specify { expect(described_class).to require_graphql_authorizations(:admin_contact) } +end diff --git a/spec/graphql/mutations/customer_relations/contacts/update_spec.rb b/spec/graphql/mutations/customer_relations/contacts/update_spec.rb new file mode 100644 index 00000000000..93bc6f53cf9 --- /dev/null +++ b/spec/graphql/mutations/customer_relations/contacts/update_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::CustomerRelations::Contacts::Update do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + + let(:first_name) { 'Lionel' } + let(:last_name) { 'Smith' } + let(:email) { 'ls@gitlab.com' } + let(:description) { 'VIP' } + let(:does_not_exist_or_no_permission) { "The resource that you are attempting to access does not exist or you don't have permission to perform this action" } + let(:contact) { create(:contact, group: group) } + let(:attributes) do + { + id: contact.to_global_id, + first_name: first_name, + last_name: last_name, + email: email, + description: description + } + end + + describe '#resolve' do + subject(:resolve_mutation) do + described_class.new(object: nil, context: { current_user: user }, field: nil).resolve( + attributes + ) + end + + context 'when the user does not have permission to update a contact' do + before do + group.add_reporter(user) + end + + it 'raises an error' do + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + .with_message(does_not_exist_or_no_permission) + end + end + + context 'when the contact does not exist' do + it 'raises an error' do + attributes[:id] = "gid://gitlab/CustomerRelations::Contact/#{non_existing_record_id}" + + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + .with_message(does_not_exist_or_no_permission) + end + end + + context 'when the user has permission to update a contact' do + before_all do + group.add_developer(user) + end + + it 'updates the organization with correct values' do + expect(resolve_mutation[:contact]).to have_attributes(attributes) + end + + context 'when the feature is disabled' do + before do + stub_feature_flags(customer_relations: false) + end + + it 'raises an error' do + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + .with_message('Feature disabled') + end + end + end + end + + specify { expect(described_class).to require_graphql_authorizations(:admin_contact) } +end diff --git a/spec/graphql/mutations/customer_relations/organizations/create_spec.rb b/spec/graphql/mutations/customer_relations/organizations/create_spec.rb index ab430b9240b..738a8d724ab 100644 --- a/spec/graphql/mutations/customer_relations/organizations/create_spec.rb +++ b/spec/graphql/mutations/customer_relations/organizations/create_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe Mutations::CustomerRelations::Organizations::Create do let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } let(:valid_params) do attributes_for(:organization, @@ -23,22 +24,19 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Create do end context 'when the user does not have permission' do - let_it_be(:group) { create(:group) } - before do - group.add_guest(user) + group.add_reporter(user) end it 'raises an error' do expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + .with_message("The resource that you are attempting to access does not exist or you don't have permission to perform this action") end end context 'when the user has permission' do - let_it_be(:group) { create(:group) } - before_all do - group.add_reporter(user) + group.add_developer(user) end context 'when the feature is disabled' do @@ -48,6 +46,7 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Create do it 'raises an error' do expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + .with_message('Feature disabled') end end diff --git a/spec/graphql/mutations/customer_relations/organizations/update_spec.rb b/spec/graphql/mutations/customer_relations/organizations/update_spec.rb index f5aa6c00301..0bc6f184fe3 100644 --- a/spec/graphql/mutations/customer_relations/organizations/update_spec.rb +++ b/spec/graphql/mutations/customer_relations/organizations/update_spec.rb @@ -4,10 +4,12 @@ require 'spec_helper' RSpec.describe Mutations::CustomerRelations::Organizations::Update do let_it_be(:user) { create(:user) } - let_it_be(:name) { 'GitLab' } - let_it_be(:default_rate) { 1000.to_f } - let_it_be(:description) { 'VIP' } + let_it_be(:group) { create(:group) } + let(:name) { 'GitLab' } + let(:default_rate) { 1000.to_f } + let(:description) { 'VIP' } + let(:does_not_exist_or_no_permission) { "The resource that you are attempting to access does not exist or you don't have permission to perform this action" } let(:organization) { create(:organization, group: group) } let(:attributes) do { @@ -26,32 +28,28 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Update do end context 'when the user does not have permission to update an organization' do - let_it_be(:group) { create(:group) } - before do - group.add_guest(user) + group.add_reporter(user) end it 'raises an error' do expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + .with_message(does_not_exist_or_no_permission) end end context 'when the organization does not exist' do - let_it_be(:group) { create(:group) } - it 'raises an error' do - attributes[:id] = 'gid://gitlab/CustomerRelations::Organization/999' + attributes[:id] = "gid://gitlab/CustomerRelations::Organization/#{non_existing_record_id}" expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + .with_message(does_not_exist_or_no_permission) end end context 'when the user has permission to update an organization' do - let_it_be(:group) { create(:group) } - before_all do - group.add_reporter(user) + group.add_developer(user) end it 'updates the organization with correct values' do @@ -65,6 +63,7 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Update do it 'raises an error' do expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + .with_message('Feature disabled') end end end diff --git a/spec/graphql/mutations/dependency_proxy/group_settings/update_spec.rb b/spec/graphql/mutations/dependency_proxy/group_settings/update_spec.rb new file mode 100644 index 00000000000..35d3224d5ba --- /dev/null +++ b/spec/graphql/mutations/dependency_proxy/group_settings/update_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::DependencyProxy::GroupSettings::Update do + using RSpec::Parameterized::TableSyntax + + let_it_be_with_reload(:group) { create(:group) } + let_it_be_with_reload(:group_settings) { create(:dependency_proxy_group_setting, group: group) } + let_it_be(:user) { create(:user) } + + let(:params) { { group_path: group.full_path, enabled: false } } + + specify { expect(described_class).to require_graphql_authorizations(:admin_dependency_proxy) } + + describe '#resolve' do + subject { described_class.new(object: group, context: { current_user: user }, field: nil).resolve(**params) } + + shared_examples 'updating the dependency proxy group settings' do + it_behaves_like 'updating the dependency proxy group settings attributes', + from: { enabled: true }, + to: { enabled: false } + + it 'returns the dependency proxy settings no errors' do + expect(subject).to eq( + dependency_proxy_setting: group_settings, + errors: [] + ) + end + end + + shared_examples 'denying access to dependency proxy group settings' do + it 'raises Gitlab::Graphql::Errors::ResourceNotAvailable' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + where(:user_role, :shared_examples_name) do + :maintainer | 'updating the dependency proxy group settings' + :developer | 'updating the dependency proxy group settings' + :reporter | 'denying access to dependency proxy group settings' + :guest | 'denying access to dependency proxy group settings' + :anonymous | 'denying access to dependency proxy group settings' + end + + with_them do + before do + stub_config(dependency_proxy: { enabled: true }) + group.send("add_#{user_role}", user) unless user_role == :anonymous + end + + it_behaves_like params[:shared_examples_name] + end + end +end diff --git a/spec/graphql/mutations/groups/update_spec.rb b/spec/graphql/mutations/groups/update_spec.rb index 2118134e8e6..620c9d6ee91 100644 --- a/spec/graphql/mutations/groups/update_spec.rb +++ b/spec/graphql/mutations/groups/update_spec.rb @@ -18,7 +18,7 @@ RSpec.describe Mutations::Groups::Update do RSpec.shared_examples 'updating the group shared runners setting' do it 'updates the group shared runners setting' do expect { subject } - .to change { group.reload.shared_runners_setting }.from('enabled').to('disabled_and_unoverridable') + .to change { group.reload.shared_runners_setting }.from('enabled').to(Namespace::SR_DISABLED_AND_UNOVERRIDABLE) end it 'returns no errors' do @@ -51,7 +51,7 @@ RSpec.describe Mutations::Groups::Update do context 'changing shared runners setting' do let_it_be(:params) do { full_path: group.full_path, - shared_runners_setting: 'disabled_and_unoverridable' } + shared_runners_setting: Namespace::SR_DISABLED_AND_UNOVERRIDABLE } end where(:user_role, :shared_examples_name) do diff --git a/spec/graphql/mutations/issues/create_spec.rb b/spec/graphql/mutations/issues/create_spec.rb index 0e7ef0e55b9..825d04ff827 100644 --- a/spec/graphql/mutations/issues/create_spec.rb +++ b/spec/graphql/mutations/issues/create_spec.rb @@ -53,7 +53,11 @@ RSpec.describe Mutations::Issues::Create do stub_spam_services end - subject { mutation.resolve(**mutation_params) } + def resolve + mutation.resolve(**mutation_params) + end + + subject { resolve } context 'when the user does not have permission to create an issue' do it 'raises an error' do @@ -61,6 +65,15 @@ RSpec.describe Mutations::Issues::Create do end end + context 'when the user has exceeded the rate limit' do + it 'raises an error' do + allow(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(true) + project.add_developer(user) + + expect { resolve }.to raise_error(RateLimitedService::RateLimitedError, _('This endpoint has been requested too many times. Try again later.')) + end + end + context 'when the user can create an issue' do context 'when creating an issue a developer' do before do diff --git a/spec/graphql/resolvers/board_list_issues_resolver_spec.rb b/spec/graphql/resolvers/board_list_issues_resolver_spec.rb index 26040f4ec1a..53d2c8a853c 100644 --- a/spec/graphql/resolvers/board_list_issues_resolver_spec.rb +++ b/spec/graphql/resolvers/board_list_issues_resolver_spec.rb @@ -31,12 +31,11 @@ RSpec.describe Resolvers::BoardListIssuesResolver do end.to raise_error(Gitlab::Graphql::Errors::ArgumentError) end - it 'returns issues in the correct order with non-nil relative positions', :aggregate_failures do + it 'returns the issues in the correct order' do # by relative_position and then ID result = resolve_board_list_issues - expect(result.map(&:id)).to eq [issue3.id, issue1.id, issue2.id, issue4.id] - expect(result.map(&:relative_position)).not_to include(nil) + expect(result.map(&:id)).to eq [issue1.id, issue3.id, issue2.id, issue4.id] end it 'finds only issues matching filters' do @@ -57,6 +56,13 @@ RSpec.describe Resolvers::BoardListIssuesResolver do expect(result).to match_array([issue1]) end + it 'filters issues by negated issue type' do + incident = create(:incident, project: project, labels: [label], relative_position: 15) + result = resolve_board_list_issues(args: { filters: { not: { types: ['issue'] } } }) + + expect(result).to contain_exactly(incident) + end + it 'raises an exception if both assignee_username and assignee_wildcard_id are present' do expect do resolve_board_list_issues(args: { filters: { assignee_username: ['username'], assignee_wildcard_id: 'NONE' } }) @@ -112,6 +118,6 @@ RSpec.describe Resolvers::BoardListIssuesResolver do end def resolve_board_list_issues(args: {}, current_user: user) - resolve(described_class, obj: list, args: args, ctx: { current_user: current_user }).items + resolve(described_class, obj: list, args: args, ctx: { current_user: current_user }) end end diff --git a/spec/graphql/resolvers/board_list_resolver_spec.rb b/spec/graphql/resolvers/board_list_resolver_spec.rb new file mode 100644 index 00000000000..5cf9e4b14ea --- /dev/null +++ b/spec/graphql/resolvers/board_list_resolver_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::BoardListResolver do + include GraphqlHelpers + include Gitlab::Graphql::Laziness + + let_it_be(:guest) { create(:user) } + let_it_be(:unauth_user) { create(:user) } + let_it_be(:group) { create(:group, :private) } + let_it_be(:group_label) { create(:group_label, group: group, name: 'Development') } + let_it_be(:board) { create(:board, resource_parent: group) } + let_it_be(:label_list) { create(:list, board: board, label: group_label) } + + describe '#resolve' do + subject { resolve_board_list(args: { id: global_id_of(label_list) }, current_user: current_user) } + + context 'with unauthorized user' do + let(:current_user) { unauth_user } + + it { is_expected.to be_nil } + end + + context 'when authorized' do + let(:current_user) { guest } + + before do + group.add_guest(guest) + end + + it { is_expected.to eq label_list } + end + end + + def resolve_board_list(args: {}, current_user: user) + force(resolve(described_class, obj: nil, args: args, ctx: { current_user: current_user })) + end +end diff --git a/spec/graphql/resolvers/clusters/agent_tokens_resolver_spec.rb b/spec/graphql/resolvers/clusters/agent_tokens_resolver_spec.rb new file mode 100644 index 00000000000..6b8b88928d8 --- /dev/null +++ b/spec/graphql/resolvers/clusters/agent_tokens_resolver_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Clusters::AgentTokensResolver do + include GraphqlHelpers + + it { expect(described_class.type).to eq(Types::Clusters::AgentTokenType) } + it { expect(described_class.null).to be_truthy } + + describe '#resolve' do + let(:agent) { create(:cluster_agent) } + let(:user) { create(:user, maintainer_projects: [agent.project]) } + let(:ctx) { Hash(current_user: user) } + + let!(:matching_token1) { create(:cluster_agent_token, agent: agent, last_used_at: 5.days.ago) } + let!(:matching_token2) { create(:cluster_agent_token, agent: agent, last_used_at: 2.days.ago) } + let!(:other_token) { create(:cluster_agent_token) } + + subject { resolve(described_class, obj: agent, ctx: ctx) } + + it 'returns tokens associated with the agent, ordered by last_used_at' do + expect(subject).to eq([matching_token2, matching_token1]) + end + + context 'user does not have permission' do + let(:user) { create(:user, developer_projects: [agent.project]) } + + it { is_expected.to be_empty } + end + end +end diff --git a/spec/graphql/resolvers/clusters/agents_resolver_spec.rb b/spec/graphql/resolvers/clusters/agents_resolver_spec.rb new file mode 100644 index 00000000000..70f40748e1d --- /dev/null +++ b/spec/graphql/resolvers/clusters/agents_resolver_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Clusters::AgentsResolver do + include GraphqlHelpers + + specify do + expect(described_class).to have_nullable_graphql_type(Types::Clusters::AgentType.connection_type) + end + + specify do + expect(described_class.field_options).to include(extras: include(:lookahead)) + end + + describe '#resolve' do + let_it_be(:project) { create(:project) } + let_it_be(:maintainer) { create(:user, maintainer_projects: [project]) } + let_it_be(:developer) { create(:user, developer_projects: [project]) } + let_it_be(:agents) { create_list(:cluster_agent, 2, project: project) } + + let(:ctx) { { current_user: current_user } } + + subject { resolve_agents } + + context 'the current user has access to clusters' do + let(:current_user) { maintainer } + + it 'finds all agents' do + expect(subject).to match_array(agents) + end + end + + context 'the current user does not have access to clusters' do + let(:current_user) { developer } + + it 'returns an empty result' do + expect(subject).to be_empty + end + end + end + + def resolve_agents(args = {}) + resolve(described_class, obj: project, ctx: ctx, lookahead: positive_lookahead, args: args) + end +end + +RSpec.describe Resolvers::Clusters::AgentsResolver.single do + it { expect(described_class).to be < Resolvers::Clusters::AgentsResolver } + + describe '.field_options' do + subject { described_class.field_options } + + specify do + expect(subject).to include( + type: ::Types::Clusters::AgentType, + null: true, + extras: [:lookahead] + ) + end + end + + describe 'arguments' do + subject { described_class.arguments[argument] } + + describe 'name' do + let(:argument) { 'name' } + + it do + expect(subject).to be_present + expect(subject.type).to be_kind_of GraphQL::Schema::NonNull + expect(subject.type.unwrap).to eq GraphQL::Types::String + expect(subject.description).to be_present + end + end + end +end diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb index e992b2b04ae..9897e697009 100644 --- a/spec/graphql/resolvers/issues_resolver_spec.rb +++ b/spec/graphql/resolvers/issues_resolver_spec.rb @@ -26,7 +26,14 @@ RSpec.describe Resolvers::IssuesResolver do expect(described_class).to have_nullable_graphql_type(Types::IssueType.connection_type) end + shared_context 'filtering for confidential issues' do + let_it_be(:confidential_issue1) { create(:issue, project: project, confidential: true) } + let_it_be(:confidential_issue2) { create(:issue, project: other_project, confidential: true) } + end + context "with a project" do + let(:obj) { project } + before_all do project.add_developer(current_user) project.add_reporter(reporter) @@ -222,6 +229,42 @@ RSpec.describe Resolvers::IssuesResolver do end end + context 'confidential issues' do + include_context 'filtering for confidential issues' + + context "when user is allowed to view confidential issues" do + it 'returns all viewable issues by default' do + expect(resolve_issues).to contain_exactly(issue1, issue2, confidential_issue1) + end + + it 'returns only the non-confidential issues for the project when filter is set to false' do + expect(resolve_issues({ confidential: false })).to contain_exactly(issue1, issue2) + end + + it "returns only the confidential issues for the project when filter is set to true" do + expect(resolve_issues({ confidential: true })).to contain_exactly(confidential_issue1) + end + end + + context "when user is not allowed to see confidential issues" do + before do + project.add_guest(current_user) + end + + it 'returns all viewable issues by default' do + expect(resolve_issues).to contain_exactly(issue1, issue2) + end + + it 'does not return the confidential issues when filter is set to false' do + expect(resolve_issues({ confidential: false })).to contain_exactly(issue1, issue2) + end + + it 'does not return the confidential issues when filter is set to true' do + expect(resolve_issues({ confidential: true })).to be_empty + end + end + end + context 'when searching issues' do it 'returns correct issues' do expect(resolve_issues(search: 'foo')).to contain_exactly(issue2) @@ -236,6 +279,36 @@ RSpec.describe Resolvers::IssuesResolver do resolve_issues(search: 'foo') end + + context 'with anonymous user' do + let_it_be(:public_project) { create(:project, :public) } + let_it_be(:public_issue) { create(:issue, project: public_project, title: 'Test issue') } + + context 'with disable_anonymous_search enabled' do + before do + stub_feature_flags(disable_anonymous_search: true) + end + + it 'returns an error' do + error_message = "User must be authenticated to include the `search` argument." + + expect { resolve(described_class, obj: public_project, args: { search: 'test' }, ctx: { current_user: nil }) } + .to raise_error(Gitlab::Graphql::Errors::ArgumentError, error_message) + end + end + + context 'with disable_anonymous_search disabled' do + before do + stub_feature_flags(disable_anonymous_search: false) + end + + it 'returns correct issues' do + expect( + resolve(described_class, obj: public_project, args: { search: 'test' }, ctx: { current_user: nil }) + ).to contain_exactly(public_issue) + end + end + end end describe 'filters by negated params' do @@ -260,6 +333,10 @@ RSpec.describe Resolvers::IssuesResolver do expect(resolve_issues(not: { assignee_id: [assignee.id] })).to contain_exactly(issue1) end + it 'returns issues without the specified issue_type' do + expect(resolve_issues(not: { types: ['issue'] })).to contain_exactly(issue1) + end + context 'when filtering by negated author' do let_it_be(:issue_by_reporter) { create(:issue, author: reporter, project: project, state: :opened) } @@ -304,7 +381,7 @@ RSpec.describe Resolvers::IssuesResolver do let_it_be(:relative_issue4) { create(:issue, project: project, relative_position: nil) } it 'sorts issues ascending' do - expect(resolve_issues(sort: :relative_position_asc).to_a).to eq [relative_issue3, relative_issue1, relative_issue4, relative_issue2] + expect(resolve_issues(sort: :relative_position_asc).to_a).to eq [relative_issue3, relative_issue1, relative_issue2, relative_issue4] end end @@ -485,26 +562,72 @@ RSpec.describe Resolvers::IssuesResolver do end context "with a group" do + let(:obj) { group } + before do group.add_developer(current_user) end describe '#resolve' do it 'finds all group issues' do - result = resolve(described_class, obj: group, ctx: { current_user: current_user }) + expect(resolve_issues).to contain_exactly(issue1, issue2, issue3) + end + + it 'returns issues without the specified issue_type' do + expect(resolve_issues({ not: { types: ['issue'] } })).to contain_exactly(issue1) + end + + context "confidential issues" do + include_context 'filtering for confidential issues' + + context "when user is allowed to view confidential issues" do + it 'returns all viewable issues by default' do + expect(resolve_issues).to contain_exactly(issue1, issue2, issue3, confidential_issue1, confidential_issue2) + end + + context 'filtering for confidential issues' do + it 'returns only the non-confidential issues for the group when filter is set to false' do + expect(resolve_issues({ confidential: false })).to contain_exactly(issue1, issue2, issue3) + end - expect(result).to contain_exactly(issue1, issue2, issue3) + it "returns only the confidential issues for the group when filter is set to true" do + expect(resolve_issues({ confidential: true })).to contain_exactly(confidential_issue1, confidential_issue2) + end + end + end + + context "when user is not allowed to see confidential issues" do + before do + group.add_guest(current_user) + end + + it 'returns all viewable issues by default' do + expect(resolve_issues).to contain_exactly(issue1, issue2, issue3) + end + + context 'filtering for confidential issues' do + it 'does not return the confidential issues when filter is set to false' do + expect(resolve_issues({ confidential: false })).to contain_exactly(issue1, issue2, issue3) + end + + it 'does not return the confidential issues when filter is set to true' do + expect(resolve_issues({ confidential: true })).to be_empty + end + end + end end end end context "when passing a non existent, batch loaded project" do - let(:project) do + let!(:project) do BatchLoader::GraphQL.for("non-existent-path").batch do |_fake_paths, loader, _| loader.call("non-existent-path", nil) end end + let(:obj) { project } + it "returns nil without breaking" do expect(resolve_issues(iids: ["don't", "break"])).to be_empty end @@ -525,6 +648,6 @@ RSpec.describe Resolvers::IssuesResolver do end def resolve_issues(args = {}, context = { current_user: current_user }) - resolve(described_class, obj: project, args: args, ctx: context) + resolve(described_class, obj: obj, args: args, ctx: context) end end diff --git a/spec/graphql/resolvers/kas/agent_configurations_resolver_spec.rb b/spec/graphql/resolvers/kas/agent_configurations_resolver_spec.rb new file mode 100644 index 00000000000..bdb1ced46ae --- /dev/null +++ b/spec/graphql/resolvers/kas/agent_configurations_resolver_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Kas::AgentConfigurationsResolver do + include GraphqlHelpers + + it { expect(described_class.type).to eq(Types::Kas::AgentConfigurationType) } + it { expect(described_class.null).to be_truthy } + it { expect(described_class.field_options).to include(calls_gitaly: true) } + + describe '#resolve' do + let_it_be(:project) { create(:project) } + + let(:user) { create(:user, maintainer_projects: [project]) } + let(:ctx) { Hash(current_user: user) } + + let(:agent1) { double } + let(:agent2) { double } + let(:kas_client) { instance_double(Gitlab::Kas::Client, list_agent_config_files: [agent1, agent2]) } + + subject { resolve(described_class, obj: project, ctx: ctx) } + + before do + allow(Gitlab::Kas::Client).to receive(:new).and_return(kas_client) + end + + it 'returns agents configured for the project' do + expect(subject).to contain_exactly(agent1, agent2) + end + + context 'an error is returned from the KAS client' do + before do + allow(kas_client).to receive(:list_agent_config_files).and_raise(GRPC::DeadlineExceeded) + end + + it 'raises a graphql error' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, 'GRPC::DeadlineExceeded') + end + end + + context 'user does not have permission' do + let(:user) { create(:user) } + + it { is_expected.to be_empty } + end + end +end diff --git a/spec/graphql/resolvers/kas/agent_connections_resolver_spec.rb b/spec/graphql/resolvers/kas/agent_connections_resolver_spec.rb new file mode 100644 index 00000000000..fe6509bcb3c --- /dev/null +++ b/spec/graphql/resolvers/kas/agent_connections_resolver_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Kas::AgentConnectionsResolver do + include GraphqlHelpers + + it { expect(described_class.type).to eq(Types::Kas::AgentConnectionType) } + it { expect(described_class.null).to be_truthy } + + describe '#resolve' do + let_it_be(:project) { create(:project) } + let_it_be(:agent1) { create(:cluster_agent, project: project) } + let_it_be(:agent2) { create(:cluster_agent, project: project) } + + let(:user) { create(:user, maintainer_projects: [project]) } + let(:ctx) { Hash(current_user: user) } + + let(:connection1) { double(agent_id: agent1.id) } + let(:connection2) { double(agent_id: agent1.id) } + let(:connection3) { double(agent_id: agent2.id) } + let(:connected_agents) { [connection1, connection2, connection3] } + let(:kas_client) { instance_double(Gitlab::Kas::Client, get_connected_agents: connected_agents) } + + subject do + batch_sync do + resolve(described_class, obj: agent1, ctx: ctx) + end + end + + before do + allow(Gitlab::Kas::Client).to receive(:new).and_return(kas_client) + end + + it 'returns active connections for the agent' do + expect(subject).to contain_exactly(connection1, connection2) + end + + it 'queries KAS once when multiple agents are requested' do + expect(kas_client).to receive(:get_connected_agents).once + + response = batch_sync do + resolve(described_class, obj: agent1, ctx: ctx) + resolve(described_class, obj: agent2, ctx: ctx) + end + + expect(response).to contain_exactly(connection3) + end + + context 'an error is returned from the KAS client' do + before do + allow(kas_client).to receive(:get_connected_agents).and_raise(GRPC::DeadlineExceeded) + end + + it 'raises a graphql error' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, 'GRPC::DeadlineExceeded') + end + end + + context 'user does not have permission' do + let(:user) { create(:user) } + + it { is_expected.to be_empty } + end + end +end diff --git a/spec/graphql/resolvers/project_pipelines_resolver_spec.rb b/spec/graphql/resolvers/project_pipelines_resolver_spec.rb index c7c00f54c0c..51a63e66b93 100644 --- a/spec/graphql/resolvers/project_pipelines_resolver_spec.rb +++ b/spec/graphql/resolvers/project_pipelines_resolver_spec.rb @@ -11,15 +11,23 @@ RSpec.describe Resolvers::ProjectPipelinesResolver do let(:current_user) { create(:user) } - before do - project.add_developer(current_user) + context 'when the user does have access' do + before do + project.add_developer(current_user) + end + + it 'resolves only MRs for the passed merge request' do + expect(resolve_pipelines).to contain_exactly(pipeline) + end end - def resolve_pipelines - resolve(described_class, obj: project, ctx: { current_user: current_user }) + context 'when the user does not have access' do + it 'does not return pipeline data' do + expect(resolve_pipelines).to be_empty + end end - it 'resolves only MRs for the passed merge request' do - expect(resolve_pipelines).to contain_exactly(pipeline) + def resolve_pipelines + resolve(described_class, obj: project, ctx: { current_user: current_user }) end end diff --git a/spec/graphql/types/base_field_spec.rb b/spec/graphql/types/base_field_spec.rb index 82efd618e38..31d07f701e8 100644 --- a/spec/graphql/types/base_field_spec.rb +++ b/spec/graphql/types/base_field_spec.rb @@ -154,6 +154,17 @@ RSpec.describe Types::BaseField do end end + describe '#resolve' do + context "late_extensions is given" do + it 'registers the late extensions after the regular extensions' do + extension_class = Class.new(GraphQL::Schema::Field::ConnectionExtension) + field = described_class.new(name: 'test', type: GraphQL::Types::String.connection_type, null: true, late_extensions: [extension_class]) + + expect(field.extensions.last.class).to be(extension_class) + end + end + end + describe '#description' do context 'feature flag given' do let(:field) { described_class.new(name: 'test', type: GraphQL::Types::String, feature_flag: flag, null: false, description: 'Test description.') } diff --git a/spec/graphql/types/board_list_type_spec.rb b/spec/graphql/types/board_list_type_spec.rb index 7976936fc1f..d78d87c57bd 100644 --- a/spec/graphql/types/board_list_type_spec.rb +++ b/spec/graphql/types/board_list_type_spec.rb @@ -3,11 +3,36 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['BoardList'] do + include GraphqlHelpers + include Gitlab::Graphql::Laziness + specify { expect(described_class.graphql_name).to eq('BoardList') } it 'has specific fields' do - expected_fields = %w[id list_type position label issues_count issues] + expected_fields = %w[id title list_type position label issues_count issues] expect(described_class).to include_graphql_fields(*expected_fields) end + + describe 'issues field' do + subject { described_class.fields['issues'] } + + it 'has a correct extension' do + is_expected.to have_graphql_extension(Gitlab::Graphql::Board::IssuesConnectionExtension) + end + end + + describe 'title' do + subject(:field) { described_class.fields['title'] } + + it 'preloads the label association' do + a, b, c = create_list(:list, 3).map { _1.class.find(_1.id) } + + baseline = ActiveRecord::QueryRecorder.new { force(resolve_field(field, a)) } + + expect do + [resolve_field(field, b), resolve_field(field, c)].each { force _1 } + end.not_to exceed_query_limit(baseline) + end + end end diff --git a/spec/graphql/types/ci/pipeline_type_spec.rb b/spec/graphql/types/ci/pipeline_type_spec.rb index 9ba4252bcd5..8c849114cf6 100644 --- a/spec/graphql/types/ci/pipeline_type_spec.rb +++ b/spec/graphql/types/ci/pipeline_type_spec.rb @@ -18,7 +18,7 @@ RSpec.describe Types::Ci::PipelineType do ] if Gitlab.ee? - expected_fields += %w[security_report_summary security_report_findings code_quality_reports] + expected_fields += %w[security_report_summary security_report_findings code_quality_reports dast_profile] end expect(described_class).to have_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/ci/runner_type_spec.rb b/spec/graphql/types/ci/runner_type_spec.rb index cff4c459d79..cf8650a4a03 100644 --- a/spec/graphql/types/ci/runner_type_spec.rb +++ b/spec/graphql/types/ci/runner_type_spec.rb @@ -11,7 +11,7 @@ RSpec.describe GitlabSchema.types['CiRunner'] do expected_fields = %w[ id description contacted_at maximum_timeout access_level active status version short_sha revision locked run_untagged ip_address runner_type tag_list - project_count job_count + project_count job_count admin_url user_permissions ] expect(described_class).to include_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/clusters/agent_token_type_spec.rb b/spec/graphql/types/clusters/agent_token_type_spec.rb new file mode 100644 index 00000000000..c872d201fd9 --- /dev/null +++ b/spec/graphql/types/clusters/agent_token_type_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['ClusterAgentToken'] do + let(:fields) { %i[cluster_agent created_at created_by_user description id last_used_at name] } + + it { expect(described_class.graphql_name).to eq('ClusterAgentToken') } + + it { expect(described_class).to require_graphql_authorizations(:admin_cluster) } + + it { expect(described_class).to have_graphql_fields(fields) } +end diff --git a/spec/graphql/types/clusters/agent_type_spec.rb b/spec/graphql/types/clusters/agent_type_spec.rb new file mode 100644 index 00000000000..4b4b601b230 --- /dev/null +++ b/spec/graphql/types/clusters/agent_type_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['ClusterAgent'] do + let(:fields) { %i[created_at created_by_user id name project updated_at tokens web_path connections] } + + it { expect(described_class.graphql_name).to eq('ClusterAgent') } + + it { expect(described_class).to require_graphql_authorizations(:admin_cluster) } + + it { expect(described_class).to have_graphql_fields(fields) } +end diff --git a/spec/graphql/types/container_expiration_policy_older_than_enum_spec.rb b/spec/graphql/types/container_expiration_policy_older_than_enum_spec.rb index 72ab605f2e6..1989b87a28f 100644 --- a/spec/graphql/types/container_expiration_policy_older_than_enum_spec.rb +++ b/spec/graphql/types/container_expiration_policy_older_than_enum_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['ContainerExpirationPolicyOlderThanEnum'] do - let_it_be(:expected_values) { %w[SEVEN_DAYS FOURTEEN_DAYS THIRTY_DAYS NINETY_DAYS] } + let_it_be(:expected_values) { %w[SEVEN_DAYS FOURTEEN_DAYS THIRTY_DAYS SIXTY_DAYS NINETY_DAYS] } it_behaves_like 'exposing container expiration policy option', :older_than end diff --git a/spec/graphql/types/error_tracking/sentry_detailed_error_type_spec.rb b/spec/graphql/types/error_tracking/sentry_detailed_error_type_spec.rb index 8723c212486..09746750adc 100644 --- a/spec/graphql/types/error_tracking/sentry_detailed_error_type_spec.rb +++ b/spec/graphql/types/error_tracking/sentry_detailed_error_type_spec.rb @@ -10,6 +10,7 @@ RSpec.describe GitlabSchema.types['SentryDetailedError'] do it 'exposes the expected fields' do expected_fields = %i[ id + integrated sentryId title type diff --git a/spec/graphql/types/issue_type_spec.rb b/spec/graphql/types/issue_type_spec.rb index 559f347810b..c0a0fdf3b0b 100644 --- a/spec/graphql/types/issue_type_spec.rb +++ b/spec/graphql/types/issue_type_spec.rb @@ -18,7 +18,7 @@ RSpec.describe GitlabSchema.types['Issue'] do confidential hidden discussion_locked upvotes downvotes merge_requests_count user_notes_count user_discussions_count web_path web_url relative_position emails_disabled subscribed time_estimate total_time_spent human_time_estimate human_total_time_spent closed_at created_at updated_at task_completion_status design_collection alert_management_alert severity current_user_todos moved moved_to - create_note_email timelogs project_id] + create_note_email timelogs project_id customer_relations_contacts] fields.each do |field_name| expect(described_class).to have_graphql_field(field_name) diff --git a/spec/graphql/types/kas/agent_configuration_type_spec.rb b/spec/graphql/types/kas/agent_configuration_type_spec.rb new file mode 100644 index 00000000000..e6cccfa56d2 --- /dev/null +++ b/spec/graphql/types/kas/agent_configuration_type_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['AgentConfiguration'] do + let(:fields) { %i[agent_name] } + + it { expect(described_class.graphql_name).to eq('AgentConfiguration') } + it { expect(described_class.description).to eq('Configuration details for an Agent') } + it { expect(described_class).to have_graphql_fields(fields) } +end diff --git a/spec/graphql/types/kas/agent_connection_type_spec.rb b/spec/graphql/types/kas/agent_connection_type_spec.rb new file mode 100644 index 00000000000..0990d02af11 --- /dev/null +++ b/spec/graphql/types/kas/agent_connection_type_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Kas::AgentConnectionType do + include GraphqlHelpers + + let(:fields) { %i[connected_at connection_id metadata] } + + it { expect(described_class.graphql_name).to eq('ConnectedAgent') } + it { expect(described_class.description).to eq('Connection details for an Agent') } + it { expect(described_class).to have_graphql_fields(fields) } + + describe '#connected_at' do + let(:connected_at) { double(Google::Protobuf::Timestamp, seconds: 123456, nanos: 654321) } + let(:object) { double(Gitlab::Agent::AgentTracker::ConnectedAgentInfo, connected_at: connected_at) } + + it 'converts the seconds value to a timestamp' do + expect(resolve_field(:connected_at, object)).to eq(Time.at(connected_at.seconds)) + end + end +end diff --git a/spec/graphql/types/kas/agent_metadata_type_spec.rb b/spec/graphql/types/kas/agent_metadata_type_spec.rb new file mode 100644 index 00000000000..ebc12ebb72a --- /dev/null +++ b/spec/graphql/types/kas/agent_metadata_type_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Kas::AgentMetadataType do + include GraphqlHelpers + + let(:fields) { %i[version commit pod_namespace pod_name] } + + it { expect(described_class.graphql_name).to eq('AgentMetadata') } + it { expect(described_class.description).to eq('Information about a connected Agent') } + it { expect(described_class).to have_graphql_fields(fields) } +end diff --git a/spec/graphql/types/packages/nuget/metadatum_type_spec.rb b/spec/graphql/types/packages/nuget/metadatum_type_spec.rb index e5baa7522e4..94a1dbaee43 100644 --- a/spec/graphql/types/packages/nuget/metadatum_type_spec.rb +++ b/spec/graphql/types/packages/nuget/metadatum_type_spec.rb @@ -10,4 +10,10 @@ RSpec.describe GitlabSchema.types['NugetMetadata'] do expect(described_class).to include_graphql_fields(*expected_fields) end + + %w[projectUrl licenseUrl iconUrl].each do |optional_field| + it "#{optional_field} can be null" do + expect(described_class.fields[optional_field].type).to be_nullable + end + end end diff --git a/spec/graphql/types/packages/package_type_spec.rb b/spec/graphql/types/packages/package_type_spec.rb index 07573044abb..3267c765dc7 100644 --- a/spec/graphql/types/packages/package_type_spec.rb +++ b/spec/graphql/types/packages/package_type_spec.rb @@ -9,7 +9,7 @@ RSpec.describe GitlabSchema.types['Package'] do created_at updated_at project tags pipelines metadata versions - status + status can_destroy ] expect(described_class).to include_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/permission_types/ci/runner_spec.rb b/spec/graphql/types/permission_types/ci/runner_spec.rb new file mode 100644 index 00000000000..e5fbbb346e4 --- /dev/null +++ b/spec/graphql/types/permission_types/ci/runner_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::PermissionTypes::Ci::Runner do + it do + expected_permissions = [ + :read_runner, :update_runner, :delete_runner + ] + + expected_permissions.each do |permission| + expect(described_class).to have_graphql_field(permission) + end + end +end diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb index d825bd7ebd4..45a718683be 100644 --- a/spec/graphql/types/project_type_spec.rb +++ b/spec/graphql/types/project_type_spec.rb @@ -33,6 +33,7 @@ RSpec.describe GitlabSchema.types['Project'] do issue_status_counts terraform_states alert_management_integrations container_repositories container_repositories_count pipeline_analytics squash_read_only sast_ci_configuration + cluster_agent cluster_agents agent_configurations ci_template timelogs ] @@ -186,7 +187,7 @@ RSpec.describe GitlabSchema.types['Project'] do expect(analyzer['enabled']).to eq(true) end - context "with guest user" do + context 'with guest user' do before do project.add_guest(user) end @@ -194,7 +195,7 @@ RSpec.describe GitlabSchema.types['Project'] do context 'when project is private' do let(:project) { create(:project, :private, :repository) } - it "returns no configuration" do + it 'returns no configuration' do secure_analyzers_prefix = subject.dig('data', 'project', 'sastCiConfiguration') expect(secure_analyzers_prefix).to be_nil end @@ -214,7 +215,7 @@ RSpec.describe GitlabSchema.types['Project'] do end end - context "with non-member user" do + context 'with non-member user', :sidekiq_inline do before do project.team.truncate end @@ -222,7 +223,7 @@ RSpec.describe GitlabSchema.types['Project'] do context 'when project is private' do let(:project) { create(:project, :private, :repository) } - it "returns no configuration" do + it 'returns no configuration' do secure_analyzers_prefix = subject.dig('data', 'project', 'sastCiConfiguration') expect(secure_analyzers_prefix).to be_nil end @@ -240,7 +241,7 @@ RSpec.describe GitlabSchema.types['Project'] do end context 'when repository is accessible only by team members' do - it "returns no configuration" do + it 'returns no configuration' do project.project_feature.update!( merge_requests_access_level: ProjectFeature::DISABLED, builds_access_level: ProjectFeature::DISABLED, @@ -458,4 +459,137 @@ RSpec.describe GitlabSchema.types['Project'] do it { is_expected.to have_graphql_type(Types::Ci::JobTokenScopeType) } it { is_expected.to have_graphql_resolver(Resolvers::Ci::JobTokenScopeResolver) } end + + describe 'agent_configurations' do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + agentConfigurations { + nodes { + agentName + } + } + } + } + ) + end + + let(:agent_name) { 'example-agent-name' } + let(:kas_client) { instance_double(Gitlab::Kas::Client, list_agent_config_files: [double(agent_name: agent_name)]) } + + subject { GitlabSchema.execute(query, context: { current_user: user }).as_json } + + before do + project.add_maintainer(user) + allow(Gitlab::Kas::Client).to receive(:new).and_return(kas_client) + end + + it 'returns configured agents' do + agents = subject.dig('data', 'project', 'agentConfigurations', 'nodes') + + expect(agents.count).to eq(1) + expect(agents.first['agentName']).to eq(agent_name) + end + end + + describe 'cluster_agents' do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:cluster_agent) { create(:cluster_agent, project: project, name: 'agent-name') } + let_it_be(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + clusterAgents { + count + nodes { + id + name + createdAt + updatedAt + + project { + id + } + } + } + } + } + ) + end + + subject { GitlabSchema.execute(query, context: { current_user: user }).as_json } + + before do + project.add_maintainer(user) + end + + it 'returns associated cluster agents' do + agents = subject.dig('data', 'project', 'clusterAgents', 'nodes') + + expect(agents.count).to be(1) + expect(agents.first['id']).to eq(cluster_agent.to_global_id.to_s) + expect(agents.first['name']).to eq('agent-name') + expect(agents.first['createdAt']).to be_present + expect(agents.first['updatedAt']).to be_present + expect(agents.first['project']['id']).to eq(project.to_global_id.to_s) + end + + it 'returns count of cluster agents' do + count = subject.dig('data', 'project', 'clusterAgents', 'count') + + expect(count).to be(project.cluster_agents.size) + end + end + + describe 'cluster_agent' do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:cluster_agent) { create(:cluster_agent, project: project, name: 'agent-name') } + let_it_be(:agent_token) { create(:cluster_agent_token, agent: cluster_agent) } + let_it_be(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + clusterAgent(name: "#{cluster_agent.name}") { + id + + tokens { + count + nodes { + id + } + } + } + } + } + ) + end + + subject { GitlabSchema.execute(query, context: { current_user: user }).as_json } + + before do + project.add_maintainer(user) + end + + it 'returns associated cluster agents' do + agent = subject.dig('data', 'project', 'clusterAgent') + tokens = agent.dig('tokens', 'nodes') + + expect(agent['id']).to eq(cluster_agent.to_global_id.to_s) + + expect(tokens.count).to be(1) + expect(tokens.first['id']).to eq(agent_token.to_global_id.to_s) + end + + it 'returns count of agent tokens' do + agent = subject.dig('data', 'project', 'clusterAgent') + count = agent.dig('tokens', 'count') + + expect(cluster_agent.agent_tokens.size).to be(count) + end + end end diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb index 6a43867f1fe..14ef03a64f9 100644 --- a/spec/graphql/types/query_type_spec.rb +++ b/spec/graphql/types/query_type_spec.rb @@ -27,6 +27,7 @@ RSpec.describe GitlabSchema.types['Query'] do runner runners timelogs + board_list ] expect(described_class).to have_graphql_fields(*expected_fields).at_least @@ -136,4 +137,14 @@ RSpec.describe GitlabSchema.types['Query'] do is_expected.to have_graphql_resolver(Resolvers::TimelogResolver) end end + + describe 'boardList field' do + subject { described_class.fields['boardList'] } + + it 'finds a board list by its gid' do + is_expected.to have_graphql_arguments(:id, :issue_filters) + is_expected.to have_graphql_type(Types::BoardListType) + is_expected.to have_graphql_resolver(Resolvers::BoardListResolver) + end + end end diff --git a/spec/helpers/application_settings_helper_spec.rb b/spec/helpers/application_settings_helper_spec.rb index ef5f6931d02..3c2ac954fe5 100644 --- a/spec/helpers/application_settings_helper_spec.rb +++ b/spec/helpers/application_settings_helper_spec.rb @@ -158,26 +158,6 @@ RSpec.describe ApplicationSettingsHelper do end end - describe '.show_documentation_base_url_field?' do - subject { helper.show_documentation_base_url_field? } - - before do - stub_feature_flags(help_page_documentation_redirect: feature_flag) - end - - context 'when feature flag is enabled' do - let(:feature_flag) { true } - - it { is_expected.to eq(true) } - end - - context 'when feature flag is disabled' do - let(:feature_flag) { false } - - it { is_expected.to eq(false) } - end - end - describe '.valid_runner_registrars' do subject { helper.valid_runner_registrars } @@ -271,18 +251,6 @@ RSpec.describe ApplicationSettingsHelper do expect(pending_user_count).to eq 1 end end - - context 'when the new_user_signups_cap is not present' do - let(:user_cap) { nil } - - it { is_expected.to eq 0 } - - it 'does not query users unnecessarily' do - expect(User).not_to receive(:blocked_pending_approval) - - pending_user_count - end - end end describe '#sidekiq_job_limiter_modes_for_select' do diff --git a/spec/helpers/avatars_helper_spec.rb b/spec/helpers/avatars_helper_spec.rb index 047a6ca0b7d..7190f2fcd4a 100644 --- a/spec/helpers/avatars_helper_spec.rb +++ b/spec/helpers/avatars_helper_spec.rb @@ -7,7 +7,7 @@ RSpec.describe AvatarsHelper do let_it_be(:user) { create(:user) } - describe '#project_icon & #group_icon' do + describe '#project_icon, #group_icon, #topic_icon' do shared_examples 'resource with a default avatar' do |source_type| it 'returns a default avatar div' do expect(public_send("#{source_type}_icon", *helper_args)) @@ -71,6 +71,18 @@ RSpec.describe AvatarsHelper do let(:helper_args) { [resource] } end end + + context 'when providing a topic' do + it_behaves_like 'resource with a default avatar', 'topic' do + let(:resource) { create(:topic, name: 'foo') } + let(:helper_args) { [resource] } + end + + it_behaves_like 'resource with a custom avatar', 'topic' do + let(:resource) { create(:topic, avatar: File.open(uploaded_image_temp_path)) } + let(:helper_args) { [resource] } + end + end end describe '#avatar_icon_for' do diff --git a/spec/helpers/ci/runners_helper_spec.rb b/spec/helpers/ci/runners_helper_spec.rb index 0f15f8be0a9..49ea2ac8d3b 100644 --- a/spec/helpers/ci/runners_helper_spec.rb +++ b/spec/helpers/ci/runners_helper_spec.rb @@ -83,7 +83,7 @@ RSpec.describe Ci::RunnersHelper do data = group_shared_runners_settings_data(group) expect(data[:update_path]).to eq("/api/v4/groups/#{group.id}") - expect(data[:shared_runners_availability]).to eq('disabled_and_unoverridable') + expect(data[:shared_runners_availability]).to eq(Namespace::SR_DISABLED_AND_UNOVERRIDABLE) expect(data[:parent_shared_runners_availability]).to eq('enabled') end end @@ -137,16 +137,15 @@ RSpec.describe Ci::RunnersHelper do using RSpec::Parameterized::TableSyntax where(:shared_runners_setting, :is_disabled_and_unoverridable) do - 'enabled' | "false" - 'disabled_with_override' | "false" - 'disabled_and_unoverridable' | "true" + :shared_runners_enabled | "false" + :disabled_with_override | "false" + :disabled_and_unoverridable | "true" end with_them do it 'returns the override runner status for project with group' do - group = create(:group) - project = create(:project, group: group) - allow(group).to receive(:shared_runners_setting).and_return(shared_runners_setting) + group = create(:group, shared_runners_setting) + project = create(:project, group: group, shared_runners_enabled: false) data = helper.toggle_shared_runners_settings_data(project) expect(data[:is_disabled_and_unoverridable]).to eq(is_disabled_and_unoverridable) diff --git a/spec/helpers/container_expiration_policies_helper_spec.rb b/spec/helpers/container_expiration_policies_helper_spec.rb index 7ad3804e3a9..acb6b017d2c 100644 --- a/spec/helpers/container_expiration_policies_helper_spec.rb +++ b/spec/helpers/container_expiration_policies_helper_spec.rb @@ -40,6 +40,7 @@ RSpec.describe ContainerExpirationPoliciesHelper do { key: '7d', label: '7 days until tags are automatically removed' }, { key: '14d', label: '14 days until tags are automatically removed' }, { key: '30d', label: '30 days until tags are automatically removed' }, + { key: '60d', label: '60 days until tags are automatically removed' }, { key: '90d', label: '90 days until tags are automatically removed', default: true } ] diff --git a/spec/helpers/feature_flags_helper_spec.rb b/spec/helpers/feature_flags_helper_spec.rb index 9a080736595..228459277ca 100644 --- a/spec/helpers/feature_flags_helper_spec.rb +++ b/spec/helpers/feature_flags_helper_spec.rb @@ -3,10 +3,20 @@ require 'spec_helper' RSpec.describe FeatureFlagsHelper do + include Devise::Test::ControllerHelpers + let_it_be(:project) { create(:project) } let_it_be(:feature_flag) { create(:operations_feature_flag, project: project) } let_it_be(:user) { create(:user) } + before do + allow(helper).to receive(:can?).and_return(true) + allow(helper).to receive(:current_user).and_return(user) + + self.instance_variable_set(:@project, project) + self.instance_variable_set(:@feature_flag, feature_flag) + end + describe '#unleash_api_url' do subject { helper.unleash_api_url(project) } @@ -18,4 +28,17 @@ RSpec.describe FeatureFlagsHelper do it { is_expected.not_to be_empty } end + + describe '#edit_feature_flag_data' do + subject { helper.edit_feature_flag_data } + + it 'contains all the data needed to edit feature flags' do + is_expected.to include(endpoint: "/#{project.full_path}/-/feature_flags/#{feature_flag.iid}", + project_id: project.id, + feature_flags_path: "/#{project.full_path}/-/feature_flags", + environments_endpoint: "/#{project.full_path}/-/environments/search.json", + strategy_type_docs_page_path: "/help/operations/feature_flags#feature-flag-strategies", + environments_scope_docs_path: "/help/ci/environments/index.md#scope-environments-with-specs") + end + end end diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb index 825d5236b5d..4d647696130 100644 --- a/spec/helpers/groups_helper_spec.rb +++ b/spec/helpers/groups_helper_spec.rb @@ -146,7 +146,7 @@ RSpec.describe GroupsHelper do let(:possible_help_texts) do { - default_help: "This setting will be applied to all subgroups unless overridden by a group owner", + default_help: "Applied to all subgroups unless overridden by a group owner.", ancestor_locked_but_you_can_override: %r{This setting is applied on <a .+>.+</a>\. You can override the setting or .+}, ancestor_locked_so_ask_the_owner: /This setting is applied on .+\. To share projects in this group with another group, ask the owner to override the setting or remove the share with group lock from .+/, ancestor_locked_and_has_been_overridden: /This setting is applied on .+ and has been overridden on this subgroup/ diff --git a/spec/helpers/hooks_helper_spec.rb b/spec/helpers/hooks_helper_spec.rb index 92e082c4974..3b23d705790 100644 --- a/spec/helpers/hooks_helper_spec.rb +++ b/spec/helpers/hooks_helper_spec.rb @@ -6,9 +6,10 @@ RSpec.describe HooksHelper do let(:project) { create(:project) } let(:project_hook) { create(:project_hook, project: project) } let(:system_hook) { create(:system_hook) } - let(:trigger) { 'push_events' } describe '#link_to_test_hook' do + let(:trigger) { 'push_events' } + it 'returns project namespaced link' do expect(helper.link_to_test_hook(project_hook, trigger)) .to include("href=\"#{test_project_hook_path(project, project_hook, trigger: trigger)}\"") @@ -19,4 +20,24 @@ RSpec.describe HooksHelper do .to include("href=\"#{test_admin_hook_path(system_hook, trigger: trigger)}\"") end end + + describe '#hook_log_path' do + context 'with a project hook' do + let(:web_hook_log) { create(:web_hook_log, web_hook: project_hook) } + + it 'returns project-namespaced link' do + expect(helper.hook_log_path(project_hook, web_hook_log)) + .to eq(web_hook_log.present.details_path) + end + end + + context 'with a system hook' do + let(:web_hook_log) { create(:web_hook_log, web_hook: system_hook) } + + it 'returns admin-namespaced link' do + expect(helper.hook_log_path(system_hook, web_hook_log)) + .to eq(admin_hook_hook_log_path(system_hook, web_hook_log)) + end + end + end end diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index 3eb3c73cfcc..30049745433 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -133,13 +133,13 @@ RSpec.describe IssuablesHelper do it 'returns navigation with badges' do expect(helper.issuables_state_counter_text(:issues, :opened, true)) - .to eq('<span>Open</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm">42</span>') + .to eq('<span>Open</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex">42</span>') expect(helper.issuables_state_counter_text(:issues, :closed, true)) - .to eq('<span>Closed</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm">42</span>') + .to eq('<span>Closed</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex">42</span>') expect(helper.issuables_state_counter_text(:merge_requests, :merged, true)) - .to eq('<span>Merged</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm">42</span>') + .to eq('<span>Merged</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex">42</span>') expect(helper.issuables_state_counter_text(:merge_requests, :all, true)) - .to eq('<span>All</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm">42</span>') + .to eq('<span>All</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex">42</span>') end end @@ -176,7 +176,7 @@ RSpec.describe IssuablesHelper do it 'returns complete count' do expect(helper.issuables_state_counter_text(:issues, :opened, true)) - .to eq('<span>Open</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm">1,100</span>') + .to eq('<span>Open</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex">1,100</span>') end end @@ -187,7 +187,7 @@ RSpec.describe IssuablesHelper do it 'returns truncated count' do expect(helper.issuables_state_counter_text(:issues, :opened, true)) - .to eq('<span>Open</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm">1.1k</span>') + .to eq('<span>Open</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex">1.1k</span>') end end end diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index f5f26d306fb..850051c7875 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -354,6 +354,7 @@ RSpec.describe IssuesHelper do let(:group) { create(:group) } let(:current_user) { double.as_null_object } let(:issues) { [] } + let(:projects) { [] } it 'returns expected result' do allow(helper).to receive(:current_user).and_return(current_user) @@ -367,13 +368,14 @@ RSpec.describe IssuesHelper do empty_state_svg_path: '#', full_path: group.full_path, has_any_issues: issues.to_a.any?.to_s, + has_any_projects: any_projects?(projects).to_s, is_signed_in: current_user.present?.to_s, jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'), rss_path: '#', sign_in_path: new_user_session_path } - expect(helper.group_issues_list_data(group, current_user, issues)).to include(expected) + expect(helper.group_issues_list_data(group, current_user, issues, projects)).to include(expected) end end diff --git a/spec/helpers/one_trust_helper_spec.rb b/spec/helpers/one_trust_helper_spec.rb new file mode 100644 index 00000000000..85c38885304 --- /dev/null +++ b/spec/helpers/one_trust_helper_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe OneTrustHelper do + describe '#one_trust_enabled?' do + let(:user) { nil } + + before do + stub_config(extra: { one_trust_id: SecureRandom.uuid }) + allow(helper).to receive(:current_user).and_return(user) + end + + subject(:one_trust_enabled?) { helper.one_trust_enabled? } + + context 'with ecomm_instrumentation feature flag disabled' do + before do + stub_feature_flags(ecomm_instrumentation: false) + end + + context 'when id is set and no user is set' do + let(:user) { instance_double('User') } + + it { is_expected.to be_falsey } + end + end + + context 'with ecomm_instrumentation feature flag enabled' do + context 'when current user is set' do + let(:user) { instance_double('User') } + + it { is_expected.to be_falsey } + end + + context 'when no id is set' do + before do + stub_config(extra: {}) + end + + it { is_expected.to be_falsey } + end + + context 'when id is set and no user is set' do + it { is_expected.to be_truthy } + end + end + end +end diff --git a/spec/helpers/packages_helper_spec.rb b/spec/helpers/packages_helper_spec.rb index 06c6cccd488..2af572850da 100644 --- a/spec/helpers/packages_helper_spec.rb +++ b/spec/helpers/packages_helper_spec.rb @@ -260,4 +260,34 @@ RSpec.describe PackagesHelper do end end end + + describe '#packages_list_data' do + let_it_be(:resource) { project } + let_it_be(:type) { 'project' } + + let(:expected_result) do + { + resource_id: resource.id, + full_path: resource.full_path, + page_type: type + } + end + + subject(:result) { helper.packages_list_data(type, resource) } + + context 'at a project level' do + it 'populates presenter data' do + expect(result).to match(hash_including(expected_result)) + end + end + + context 'at a group level' do + let_it_be(:resource) { create(:group) } + let_it_be(:type) { 'group' } + + it 'populates presenter data' do + expect(result).to match(hash_including(expected_result)) + end + end + end end diff --git a/spec/helpers/projects/cluster_agents_helper_spec.rb b/spec/helpers/projects/cluster_agents_helper_spec.rb new file mode 100644 index 00000000000..2935a74586b --- /dev/null +++ b/spec/helpers/projects/cluster_agents_helper_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::ClusterAgentsHelper do + describe '#js_cluster_agent_details_data' do + let_it_be(:project) { create(:project) } + + let(:agent_name) { 'agent-name' } + + subject { helper.js_cluster_agent_details_data(agent_name, project) } + + it 'returns name' do + expect(subject[:agent_name]).to eq(agent_name) + end + + it 'returns project path' do + expect(subject[:project_path]).to eq(project.full_path) + end + end +end diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 85b572d3f68..1100f4a3ad5 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -314,13 +314,13 @@ RSpec.describe ProjectsHelper do end it 'returns image tag for member avatar' do - expect(helper).to receive(:image_tag).with(expected, { width: 16, class: %w[avatar avatar-inline s16], alt: "", "data-src" => anything }) + expect(helper).to receive(:image_tag).with(expected, { width: 16, class: %w[avatar avatar-inline s16], alt: "" }) helper.link_to_member_avatar(user) end it 'returns image tag with avatar class' do - expect(helper).to receive(:image_tag).with(expected, { width: 16, class: %w[avatar avatar-inline s16 any-avatar-class], alt: "", "data-src" => anything }) + expect(helper).to receive(:image_tag).with(expected, { width: 16, class: %w[avatar avatar-inline s16 any-avatar-class], alt: "" }) helper.link_to_member_avatar(user, avatar_class: "any-avatar-class") end @@ -904,6 +904,14 @@ RSpec.describe ProjectsHelper do it { is_expected.to be_falsey } end + + context 'the :show_terraform_banner feature flag is disabled' do + before do + stub_feature_flags(show_terraform_banner: false) + end + + it { is_expected.to be_falsey } + end end end diff --git a/spec/helpers/routing/pseudonymization_helper_spec.rb b/spec/helpers/routing/pseudonymization_helper_spec.rb index 10563502555..a28a86d1f53 100644 --- a/spec/helpers/routing/pseudonymization_helper_spec.rb +++ b/spec/helpers/routing/pseudonymization_helper_spec.rb @@ -6,6 +6,7 @@ RSpec.describe ::Routing::PseudonymizationHelper do let_it_be(:group) { create(:group) } let_it_be(:subgroup) { create(:group, parent: group) } let_it_be(:project) { create(:project, group: group) } + let_it_be(:subproject) { create(:project, group: subgroup) } let_it_be(:issue) { create(:issue, project: project) } let(:merge_request) { create(:merge_request, source_project: project) } @@ -56,16 +57,16 @@ RSpec.describe ::Routing::PseudonymizationHelper do end context 'with controller for groups with subgroups and project' do - let(:masked_url) { "http://test.host/namespace:#{subgroup.id}/project:#{project.id}"} + let(:masked_url) { "http://test.host/namespace:#{subgroup.id}/project:#{subproject.id}"} before do allow(helper).to receive(:group).and_return(subgroup) - allow(helper.project).to receive(:namespace).and_return(subgroup) + allow(helper).to receive(:project).and_return(subproject) allow(Rails.application.routes).to receive(:recognize_path).and_return({ controller: 'projects', action: 'show', namespace_id: subgroup.name, - id: project.name + id: subproject.name }) end @@ -129,6 +130,29 @@ RSpec.describe ::Routing::PseudonymizationHelper do end end + describe 'when it raises exception' do + context 'calls error tracking' do + before do + controller.request.path = '/dashboard/issues' + controller.request.query_string = 'assignee_username=root' + allow(Rails.application.routes).to receive(:recognize_path).and_return({ + controller: 'dashboard', + action: 'issues' + }) + end + + it 'sends error to sentry and returns nil' do + allow(helper).to receive(:mask_params).with(anything).and_raise(ActionController::RoutingError, 'Some routing error') + + expect(Gitlab::ErrorTracking).to receive(:track_exception).with( + ActionController::RoutingError, + url: '/dashboard/issues?assignee_username=root').and_call_original + + expect(helper.masked_page_url).to be_nil + end + end + end + describe 'when feature flag is disabled' do before do stub_feature_flags(mask_page_urls: false) diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb index 7b2334ab79e..9e870658870 100644 --- a/spec/helpers/search_helper_spec.rb +++ b/spec/helpers/search_helper_spec.rb @@ -248,13 +248,13 @@ RSpec.describe SearchHelper do it 'uses the correct singular label' do collection = Kaminari.paginate_array([:foo]).page(1).per(10) - expect(search_entries_info(collection, scope, 'foo')).to eq("Showing 1 #{label} for<span> <code>foo</code> </span>") + expect(search_entries_info(collection, scope, 'foo')).to eq("Showing 1 #{label} for <span> <code>foo</code> </span>") end it 'uses the correct plural label' do collection = Kaminari.paginate_array([:foo] * 23).page(1).per(10) - expect(search_entries_info(collection, scope, 'foo')).to eq("Showing 1 - 10 of 23 #{label.pluralize} for<span> <code>foo</code> </span>") + expect(search_entries_info(collection, scope, 'foo')).to eq("Showing 1 - 10 of 23 #{label.pluralize} for <span> <code>foo</code> </span>") end end diff --git a/spec/helpers/startupjs_helper_spec.rb b/spec/helpers/startupjs_helper_spec.rb index 6d61c38d4a5..8d429b59291 100644 --- a/spec/helpers/startupjs_helper_spec.rb +++ b/spec/helpers/startupjs_helper_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe StartupjsHelper do + using RSpec::Parameterized::TableSyntax + describe '#page_startup_graphql_calls' do let(:query_location) { 'repository/path_last_commit' } let(:query_content) do @@ -17,4 +19,24 @@ RSpec.describe StartupjsHelper do expect(startup_graphql_calls).to include({ query: query_content, variables: { ref: 'foo' } }) end end + + describe '#page_startup_graphql_headers' do + where(:csrf_token, :feature_category, :expected) do + 'abc' | 'web_ide' | { 'X-CSRF-Token' => 'abc', 'x-gitlab-feature-category' => 'web_ide' } + '' | '' | { 'X-CSRF-Token' => '', 'x-gitlab-feature-category' => '' } + 'abc' | nil | { 'X-CSRF-Token' => 'abc', 'x-gitlab-feature-category' => '' } + 'something' | ' ' | { 'X-CSRF-Token' => 'something', 'x-gitlab-feature-category' => '' } + end + + with_them do + before do + allow(helper).to receive(:form_authenticity_token).and_return(csrf_token) + ::Gitlab::ApplicationContext.push(feature_category: feature_category) + end + + it 'returns hash of headers for GraphQL requests' do + expect(helper.page_startup_graphql_headers).to eq(expected) + end + end + end end diff --git a/spec/helpers/tab_helper_spec.rb b/spec/helpers/tab_helper_spec.rb index bd8a8fa174a..346bfc7850c 100644 --- a/spec/helpers/tab_helper_spec.rb +++ b/spec/helpers/tab_helper_spec.rb @@ -5,6 +5,60 @@ require 'spec_helper' RSpec.describe TabHelper do include ApplicationHelper + describe 'gl_tabs_nav' do + it 'creates a tabs navigation' do + expect(gl_tabs_nav).to match(%r{<ul class=".*" role="tablist"><\/ul>}) + end + + it 'captures block output' do + expect(gl_tabs_nav { "block content" }).to match(/block content/) + end + + it 'adds styles classes' do + expect(gl_tabs_nav).to match(/class="nav gl-tabs-nav"/) + end + + it 'adds custom class' do + expect(gl_tabs_nav(class: 'my-class' )).to match(/class=".*my-class.*"/) + end + end + + describe 'gl_tab_link_to' do + before do + allow(self).to receive(:current_page?).and_return(false) + end + + it 'creates a tab' do + expect(gl_tab_link_to('Link', '/url')).to eq('<li class="nav-item" role="presentation"><a class="nav-link gl-tab-nav-item" href="/url">Link</a></li>') + end + + it 'creates a tab with block output' do + expect(gl_tab_link_to('/url') { 'block content' }).to match(/block content/) + end + + it 'creates a tab with custom classes' do + expect(gl_tab_link_to('Link', '/url', { class: 'my-class' })).to match(/<a class=".*my-class.*"/) + end + + it 'creates an active tab with item_active = true' do + expect(gl_tab_link_to('Link', '/url', { item_active: true })).to match(/<a class=".*active gl-tab-nav-item-active gl-tab-nav-item-active-indigo.*"/) + end + + context 'when on the active page' do + before do + allow(self).to receive(:current_page?).and_return(true) + end + + it 'creates an active tab' do + expect(gl_tab_link_to('Link', '/url')).to match(/<a class=".*active gl-tab-nav-item-active gl-tab-nav-item-active-indigo.*"/) + end + + it 'creates an inactive tab with item_active = false' do + expect(gl_tab_link_to('Link', '/url', { item_active: false })).not_to match(/<a class=".*active.*"/) + end + end + end + describe 'nav_link' do using RSpec::Parameterized::TableSyntax diff --git a/spec/helpers/time_zone_helper_spec.rb b/spec/helpers/time_zone_helper_spec.rb index e6cb20b5800..43ad130c4b5 100644 --- a/spec/helpers/time_zone_helper_spec.rb +++ b/spec/helpers/time_zone_helper_spec.rb @@ -76,6 +76,18 @@ RSpec.describe TimeZoneHelper, :aggregate_failures do travel_to Time.find_zone(timezone).local(2021, 7, 20, 15, 30, 45) end + context 'when timezone is `nil`' do + it 'returns `nil`' do + expect(helper.local_time(nil)).to eq(nil) + end + end + + context 'when timezone is blank' do + it 'returns `nil`' do + expect(helper.local_time('')).to eq(nil) + end + end + context 'when a valid timezone is passed' do it 'returns local time' do expect(helper.local_time(timezone)).to eq('3:30 PM') diff --git a/spec/helpers/user_callouts_helper_spec.rb b/spec/helpers/user_callouts_helper_spec.rb index 794ff5ee945..f738ba855b8 100644 --- a/spec/helpers/user_callouts_helper_spec.rb +++ b/spec/helpers/user_callouts_helper_spec.rb @@ -293,4 +293,37 @@ RSpec.describe UserCalloutsHelper do it { is_expected.to eq(false) } end end + + describe '.show_security_newsletter_user_callout?' do + let_it_be(:admin) { create(:user, :admin) } + + subject { helper.show_security_newsletter_user_callout? } + + context 'when `current_user` is not an admin' do + before do + allow(helper).to receive(:current_user).and_return(user) + allow(helper).to receive(:user_dismissed?).with(described_class::SECURITY_NEWSLETTER_CALLOUT) { false } + end + + it { is_expected.to be false } + end + + context 'when user has dismissed callout' do + before do + allow(helper).to receive(:current_user).and_return(admin) + allow(helper).to receive(:user_dismissed?).with(described_class::SECURITY_NEWSLETTER_CALLOUT) { true } + end + + it { is_expected.to be false } + end + + context 'when `current_user` is an admin and user has not dismissed callout' do + before do + allow(helper).to receive(:current_user).and_return(admin) + allow(helper).to receive(:user_dismissed?).with(described_class::SECURITY_NEWSLETTER_CALLOUT) { false } + end + + it { is_expected.to be true } + end + end end diff --git a/spec/initializers/carrierwave_patch_spec.rb b/spec/initializers/carrierwave_patch_spec.rb index cbdad4aa9ac..e219db2299d 100644 --- a/spec/initializers/carrierwave_patch_spec.rb +++ b/spec/initializers/carrierwave_patch_spec.rb @@ -81,19 +81,32 @@ RSpec.describe 'CarrierWave::Storage::Fog::File' do end describe '#authenticated_url' do + let(:expire_at) { 24.hours.from_now } + let(:options) { { expire_at: expire_at } } + it 'has an authenticated URL' do - expect(subject.authenticated_url).to eq("https://sa.blob.core.windows.net/test_container/test_blob?token") + expect(subject.authenticated_url(options)).to eq("https://sa.blob.core.windows.net/test_container/test_blob?token") end context 'with custom expire_at' do it 'properly sets expires param' do - expire_at = 24.hours.from_now + expect_next_instance_of(Fog::Storage::AzureRM::File) do |file| + expect(file).to receive(:url).with(expire_at, options).and_call_original + end + + expect(subject.authenticated_url(options)).to eq("https://sa.blob.core.windows.net/test_container/test_blob?token") + end + end + + context 'with content_disposition option' do + let(:options) { { expire_at: expire_at, content_disposition: 'attachment' } } + it 'passes options' do expect_next_instance_of(Fog::Storage::AzureRM::File) do |file| - expect(file).to receive(:url).with(expire_at).and_call_original + expect(file).to receive(:url).with(expire_at, options).and_call_original end - expect(subject.authenticated_url(expire_at: expire_at)).to eq("https://sa.blob.core.windows.net/test_container/test_blob?token") + expect(subject.authenticated_url(options)).to eq("https://sa.blob.core.windows.net/test_container/test_blob?token") end end end diff --git a/spec/initializers/database_config_spec.rb b/spec/initializers/database_config_spec.rb index 5ddfbd64c23..23f7fd06254 100644 --- a/spec/initializers/database_config_spec.rb +++ b/spec/initializers/database_config_spec.rb @@ -2,19 +2,11 @@ require 'spec_helper' -RSpec.describe 'Database config initializer' do +RSpec.describe 'Database config initializer', :reestablished_active_record_base do subject do load Rails.root.join('config/initializers/database_config.rb') end - around do |example| - original_config = ActiveRecord::Base.connection_db_config - - example.run - - ActiveRecord::Base.establish_connection(original_config) - end - before do allow(Gitlab::Runtime).to receive(:max_threads).and_return(max_threads) end diff --git a/spec/initializers/lograge_spec.rb b/spec/initializers/lograge_spec.rb index 4d2aa6e74de..9e58fa289ac 100644 --- a/spec/initializers/lograge_spec.rb +++ b/spec/initializers/lograge_spec.rb @@ -230,39 +230,21 @@ RSpec.describe 'lograge', type: :request do end end - context 'when load balancing is enabled' do - before do - allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(true) - end - - context 'with db payload' do - context 'when RequestStore is enabled', :request_store do - it 'includes db counters for load balancing' do - subscriber.process_action(event) - - expect(log_data).to include(*db_load_balancing_logging_keys) - end - end - - context 'when RequestStore is disabled' do - it 'does not include db counters for load balancing' do - subscriber.process_action(event) + context 'with db payload' do + context 'when RequestStore is enabled', :request_store do + it 'includes db counters for load balancing' do + subscriber.process_action(event) - expect(log_data).not_to include(*db_load_balancing_logging_keys) - end + expect(log_data).to include(*db_load_balancing_logging_keys) end end - end - context 'when load balancing is disabled' do - before do - allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(false) - end + context 'when RequestStore is disabled' do + it 'does not include db counters for load balancing' do + subscriber.process_action(event) - it 'does not include db counters for load balancing' do - subscriber.process_action(event) - - expect(log_data).not_to include(*db_load_balancing_logging_keys) + expect(log_data).not_to include(*db_load_balancing_logging_keys) + end end end end diff --git a/spec/initializers/zz_metrics_spec.rb b/spec/initializers/zz_metrics_spec.rb deleted file mode 100644 index ad93c30ee22..00000000000 --- a/spec/initializers/zz_metrics_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'instrument_classes' do - let(:config) { double(:config) } - - before do - allow(config).to receive(:instrument_method) - allow(config).to receive(:instrument_methods) - allow(config).to receive(:instrument_instance_method) - allow(config).to receive(:instrument_instance_methods) - allow(Gitlab::Application).to receive(:configure) - end - - it 'can autoload and instrument all files' do - require_relative '../../config/initializers/zz_metrics' - expect { instrument_classes(config) }.not_to raise_error - end -end diff --git a/spec/lib/api/base_spec.rb b/spec/lib/api/base_spec.rb new file mode 100644 index 00000000000..8513b800273 --- /dev/null +++ b/spec/lib/api/base_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# rubocop:disable Rails/HttpPositionalArguments +RSpec.describe ::API::Base do + let(:app_hello) do + route = double(:route, request_method: 'GET', path: '/:version/test/hello') + double(:endpoint, route: route, options: { for: api_handler, path: ["hello"] }, namespace: '/test') + end + + let(:app_hi) do + route = double(:route, request_method: 'GET', path: '/:version//test/hi') + double(:endpoint, route: route, options: { for: api_handler, path: ["hi"] }, namespace: '/test') + end + + describe 'declare feature categories at handler level for all routes' do + let(:api_handler) do + Class.new(described_class) do + feature_category :foo + urgency :medium + + namespace '/test' do + get 'hello' do + end + post 'hi' do + end + end + end + end + + it 'sets feature category for a particular route', :aggregate_failures do + expect(api_handler.feature_category_for_app(app_hello)).to eq(:foo) + expect(api_handler.feature_category_for_app(app_hi)).to eq(:foo) + end + + it 'sets request urgency for a particular route', :aggregate_failures do + expect(api_handler.urgency_for_app(app_hello)).to be_request_urgency(:medium) + expect(api_handler.urgency_for_app(app_hi)).to be_request_urgency(:medium) + end + end + + describe 'declare feature categories at route level' do + let(:api_handler) do + Class.new(described_class) do + namespace '/test' do + get 'hello', feature_category: :foo, urgency: :low do + end + post 'hi', feature_category: :bar, urgency: :medium do + end + end + end + end + + it 'sets feature category for a particular route', :aggregate_failures do + expect(api_handler.feature_category_for_app(app_hello)).to eq(:foo) + expect(api_handler.feature_category_for_app(app_hi)).to eq(:bar) + end + + it 'sets request urgency for a particular route', :aggregate_failures do + expect(api_handler.urgency_for_app(app_hello)).to be_request_urgency(:low) + expect(api_handler.urgency_for_app(app_hi)).to be_request_urgency(:medium) + end + end + + describe 'declare feature categories at both handler level and route level' do + let(:api_handler) do + Class.new(described_class) do + feature_category :foo, ['/test/hello'] + urgency :low, ['/test/hello'] + + namespace '/test' do + get 'hello' do + end + post 'hi', feature_category: :bar, urgency: :medium do + end + end + end + end + + it 'sets feature category for a particular route', :aggregate_failures do + expect(api_handler.feature_category_for_app(app_hello)).to eq(:foo) + expect(api_handler.feature_category_for_app(app_hi)).to eq(:bar) + end + + it 'sets target duration for a particular route', :aggregate_failures do + expect(api_handler.urgency_for_app(app_hello)).to be_request_urgency(:low) + expect(api_handler.urgency_for_app(app_hi)).to be_request_urgency(:medium) + end + end +end +# rubocop:enable Rails/HttpPositionalArguments diff --git a/spec/lib/api/ci/helpers/runner_spec.rb b/spec/lib/api/ci/helpers/runner_spec.rb index 99f2db544a5..cc871d66d40 100644 --- a/spec/lib/api/ci/helpers/runner_spec.rb +++ b/spec/lib/api/ci/helpers/runner_spec.rb @@ -15,8 +15,8 @@ RSpec.describe API::Ci::Helpers::Runner do it 'handles sticking of a build when a build ID is specified' do allow(helper).to receive(:params).and_return(id: build.id) - expect(Gitlab::Database::LoadBalancing::RackMiddleware) - .to receive(:stick_or_unstick) + expect(ApplicationRecord.sticking) + .to receive(:stick_or_unstick_request) .with({}, :build, build.id) helper.current_job @@ -25,8 +25,8 @@ RSpec.describe API::Ci::Helpers::Runner do it 'does not handle sticking if no build ID was specified' do allow(helper).to receive(:params).and_return({}) - expect(Gitlab::Database::LoadBalancing::RackMiddleware) - .not_to receive(:stick_or_unstick) + expect(ApplicationRecord.sticking) + .not_to receive(:stick_or_unstick_request) helper.current_job end @@ -44,8 +44,8 @@ RSpec.describe API::Ci::Helpers::Runner do it 'handles sticking of a runner if a token is specified' do allow(helper).to receive(:params).and_return(token: runner.token) - expect(Gitlab::Database::LoadBalancing::RackMiddleware) - .to receive(:stick_or_unstick) + expect(ApplicationRecord.sticking) + .to receive(:stick_or_unstick_request) .with({}, :runner, runner.token) helper.current_runner @@ -54,8 +54,8 @@ RSpec.describe API::Ci::Helpers::Runner do it 'does not handle sticking if no token was specified' do allow(helper).to receive(:params).and_return({}) - expect(Gitlab::Database::LoadBalancing::RackMiddleware) - .not_to receive(:stick_or_unstick) + expect(ApplicationRecord.sticking) + .not_to receive(:stick_or_unstick_request) helper.current_runner end diff --git a/spec/lib/api/entities/clusters/agent_authorization_spec.rb b/spec/lib/api/entities/clusters/agent_authorization_spec.rb index 101a8af4ac4..3a1deb43bf8 100644 --- a/spec/lib/api/entities/clusters/agent_authorization_spec.rb +++ b/spec/lib/api/entities/clusters/agent_authorization_spec.rb @@ -3,15 +3,34 @@ require 'spec_helper' RSpec.describe API::Entities::Clusters::AgentAuthorization do - let_it_be(:authorization) { create(:agent_group_authorization) } - subject { described_class.new(authorization).as_json } - it 'includes basic fields' do - expect(subject).to include( - id: authorization.agent_id, - config_project: a_hash_including(id: authorization.agent.project_id), - configuration: authorization.config - ) + shared_examples 'generic authorization' do + it 'includes shared fields' do + expect(subject).to include( + id: authorization.agent_id, + config_project: a_hash_including(id: authorization.agent.project_id), + configuration: authorization.config + ) + end + end + + context 'project authorization' do + let(:authorization) { create(:agent_project_authorization) } + + include_examples 'generic authorization' + end + + context 'group authorization' do + let(:authorization) { create(:agent_group_authorization) } + + include_examples 'generic authorization' + end + + context 'implicit authorization' do + let(:agent) { create(:cluster_agent) } + let(:authorization) { Clusters::Agents::ImplicitAuthorization.new(agent: agent) } + + include_examples 'generic authorization' end end diff --git a/spec/lib/api/entities/user_spec.rb b/spec/lib/api/entities/user_spec.rb index 860f007f284..9c9a157d68a 100644 --- a/spec/lib/api/entities/user_spec.rb +++ b/spec/lib/api/entities/user_spec.rb @@ -3,10 +3,13 @@ require 'spec_helper' RSpec.describe API::Entities::User do - let(:user) { create(:user) } + let_it_be(:timezone) { 'America/Los_Angeles' } + + let(:user) { create(:user, timezone: timezone) } let(:current_user) { create(:user) } + let(:entity) { described_class.new(user, current_user: current_user) } - subject { described_class.new(user, current_user: current_user).as_json } + subject { entity.as_json } it 'exposes correct attributes' do expect(subject).to include(:bio, :location, :public_email, :skype, :linkedin, :twitter, :website_url, :organization, :job_title, :work_information, :pronouns) @@ -35,4 +38,10 @@ RSpec.describe API::Entities::User do expect(subject[:bot]).to eq(true) end end + + it 'exposes local_time' do + local_time = '2:30 PM' + expect(entity).to receive(:local_time).with(timezone).and_return(local_time) + expect(subject[:local_time]).to eq(local_time) + end end diff --git a/spec/lib/api/every_api_endpoint_spec.rb b/spec/lib/api/every_api_endpoint_spec.rb index ebf75e733d0..5fe14823a29 100644 --- a/spec/lib/api/every_api_endpoint_spec.rb +++ b/spec/lib/api/every_api_endpoint_spec.rb @@ -5,11 +5,11 @@ require 'spec_helper' RSpec.describe 'Every API endpoint' do context 'feature categories' do let_it_be(:feature_categories) do - YAML.load_file(Rails.root.join('config', 'feature_categories.yml')).map(&:to_sym).to_set + Gitlab::FeatureCategories.default.categories.map(&:to_sym).to_set end let_it_be(:api_endpoints) do - API::API.routes.map do |route| + Gitlab::RequestEndpoints.all_api_endpoints.map do |route| [route.app.options[:for], API::Base.path_for_app(route.app)] end end diff --git a/spec/lib/api/helpers_spec.rb b/spec/lib/api/helpers_spec.rb index 587fe60860a..37e040a422b 100644 --- a/spec/lib/api/helpers_spec.rb +++ b/spec/lib/api/helpers_spec.rb @@ -32,15 +32,11 @@ RSpec.describe API::Helpers do helper end - before do - allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(true) - end - it 'handles sticking when a user could be found' do allow_any_instance_of(API::Helpers).to receive(:initial_current_user).and_return(user) - expect(Gitlab::Database::LoadBalancing::RackMiddleware) - .to receive(:stick_or_unstick).with(any_args, :user, 42) + expect(ApplicationRecord.sticking) + .to receive(:stick_or_unstick_request).with(any_args, :user, 42) get 'user' @@ -50,8 +46,8 @@ RSpec.describe API::Helpers do it 'does not handle sticking if no user could be found' do allow_any_instance_of(API::Helpers).to receive(:initial_current_user).and_return(nil) - expect(Gitlab::Database::LoadBalancing::RackMiddleware) - .not_to receive(:stick_or_unstick) + expect(ApplicationRecord.sticking) + .not_to receive(:stick_or_unstick_request) get 'user' diff --git a/spec/lib/api/validations/validators/project_portable_spec.rb b/spec/lib/api/validations/validators/project_portable_spec.rb new file mode 100644 index 00000000000..8c1a49d5214 --- /dev/null +++ b/spec/lib/api/validations/validators/project_portable_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Validations::Validators::ProjectPortable do + include ApiValidatorsHelpers + + let(:portable) { 'labels' } + let(:not_portable) { 'project_members' } + + subject do + described_class.new(['test'], {}, false, scope.new) + end + + context 'valid portable' do + it 'does not raise a validation error' do + expect_no_validation_error('test' => portable) + end + end + + context 'empty params' do + it 'raises a validation error' do + expect_validation_error('test' => nil) + expect_validation_error('test' => '') + end + end + + context 'not portable' do + it 'raises a validation error' do + expect_validation_error('test' => not_portable) # Sha length > 40 + end + end +end diff --git a/spec/lib/atlassian/jira_connect/asymmetric_jwt_spec.rb b/spec/lib/atlassian/jira_connect/asymmetric_jwt_spec.rb new file mode 100644 index 00000000000..c57d8ece86b --- /dev/null +++ b/spec/lib/atlassian/jira_connect/asymmetric_jwt_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Atlassian::JiraConnect::AsymmetricJwt do + describe '#valid?' do + subject(:asymmetric_jwt) { described_class.new(jwt, verification_claims) } + + let(:verification_claims) { jwt_claims } + let(:jwt_claims) { { aud: aud, iss: client_key, qsh: qsh } } + let(:aud) { 'https://test.host/-/jira_connect' } + let(:client_key) { '1234' } + let(:qsh) { Atlassian::Jwt.create_query_string_hash('https://gitlab.test/events/installed', 'POST', 'https://gitlab.test') } + let(:public_key_id) { '123e4567-e89b-12d3-a456-426614174000' } + let(:jwt_headers) { { kid: public_key_id } } + let(:private_key) { OpenSSL::PKey::RSA.generate 2048 } + let(:jwt) { JWT.encode(jwt_claims, private_key, 'RS256', jwt_headers) } + let(:public_key) { private_key.public_key } + + before do + stub_request(:get, "https://connect-install-keys.atlassian.com/#{public_key_id}").to_return(body: public_key.to_s, status: 200) + end + + it 'returns true when verified with public key from CDN' do + expect(JWT).to receive(:decode).twice.and_call_original + + expect(asymmetric_jwt).to be_valid + + expect(WebMock).to have_requested(:get, "https://connect-install-keys.atlassian.com/#{public_key_id}") + end + + context 'JWT does not contain a key ID' do + let(:public_key_id) { nil } + + it { is_expected.not_to be_valid } + end + + context 'JWT contains a key ID that is not a valid UUID4' do + let(:public_key_id) { '123' } + + it { is_expected.not_to be_valid } + end + + context 'public key can not be retrieved' do + before do + stub_request(:get, "https://connect-install-keys.atlassian.com/#{public_key_id}").to_return(body: '', status: 404) + end + + it { is_expected.not_to be_valid } + end + + context 'retrieving the public raises an error' do + before do + allow(Gitlab::HTTP).to receive(:get).and_raise(SocketError) + end + + it { is_expected.not_to be_valid } + end + + context 'token decoding raises an error' do + before do + allow(JWT).to receive(:decode).and_call_original + allow(JWT).to receive(:decode).with( + jwt, anything, true, aud: anything, verify_aud: true, iss: client_key, verify_iss: true, algorithm: 'RS256' + ).and_raise(JWT::DecodeError) + end + + it { is_expected.not_to be_valid } + end + + context 'when iss could not be verified' do + let(:verification_claims) { { aud: jwt_claims[:aud], iss: 'some other iss', qsh: jwt_claims[:qsh] } } + + it { is_expected.not_to be_valid } + end + + context 'when qsh could not be verified' do + let(:verification_claims) { { aud: jwt_claims[:aud], iss: client_key, qsh: 'some other qsh' } } + + it { is_expected.not_to be_valid } + end + end + + describe '#iss_claim' do + subject { asymmetric_jwt.iss_claim } + + let(:asymmetric_jwt) { described_class.new('123', anything) } + + it { is_expected.to eq(nil) } + + context 'when jwt is verified' do + before do + asymmetric_jwt.instance_variable_set(:@claims, { 'iss' => 'client_key' }) + end + + it { is_expected.to eq('client_key') } + end + end +end diff --git a/spec/lib/atlassian/jira_connect/serializers/deployment_entity_spec.rb b/spec/lib/atlassian/jira_connect/serializers/deployment_entity_spec.rb index 82bcbdc4561..8ccc3253a46 100644 --- a/spec/lib/atlassian/jira_connect/serializers/deployment_entity_spec.rb +++ b/spec/lib/atlassian/jira_connect/serializers/deployment_entity_spec.rb @@ -46,12 +46,22 @@ RSpec.describe Atlassian::JiraConnect::Serializers::DeploymentEntity do using RSpec::Parameterized::TableSyntax where(:env_name, :env_type) do + 'PRODUCTION' | 'production' 'prod' | 'production' + 'prod-east-2' | 'production' + 'us-prod-east' | 'production' + 'fe-production' | 'production' 'test' | 'testing' + 'qa-env-2' | 'testing' 'staging' | 'staging' + 'pre-prod' | 'staging' + 'blue-kit-stage' | 'staging' + 'pre-prod' | 'staging' 'dev' | 'development' 'review/app' | 'development' 'something-else' | 'unmapped' + 'store-produce' | 'unmapped' + 'unproductive' | 'unmapped' end with_them do diff --git a/spec/lib/backup/gitaly_backup_spec.rb b/spec/lib/backup/gitaly_backup_spec.rb index 7797bd12f0e..2ccde517533 100644 --- a/spec/lib/backup/gitaly_backup_spec.rb +++ b/spec/lib/backup/gitaly_backup_spec.rb @@ -5,12 +5,20 @@ require 'spec_helper' RSpec.describe Backup::GitalyBackup do let(:parallel) { nil } let(:parallel_storage) { nil } + let(:progress) do Tempfile.new('progress').tap do |progress| progress.unlink end end + let(:expected_env) do + { + 'SSL_CERT_FILE' => OpenSSL::X509::DEFAULT_CERT_FILE, + 'SSL_CERT_DIR' => OpenSSL::X509::DEFAULT_CERT_DIR + }.merge(ENV) + end + after do progress.close end @@ -32,7 +40,7 @@ RSpec.describe Backup::GitalyBackup do project_snippet = create(:project_snippet, :repository, project: project) personal_snippet = create(:personal_snippet, :repository, author: project.owner) - expect(Open3).to receive(:popen2).with(ENV, anything, 'create', '-path', anything).and_call_original + expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything).and_call_original subject.start(:create) subject.enqueue(project, Gitlab::GlRepository::PROJECT) @@ -53,7 +61,7 @@ RSpec.describe Backup::GitalyBackup do let(:parallel) { 3 } it 'passes parallel option through' do - expect(Open3).to receive(:popen2).with(ENV, anything, 'create', '-path', anything, '-parallel', '3').and_call_original + expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything, '-parallel', '3').and_call_original subject.start(:create) subject.wait @@ -64,7 +72,7 @@ RSpec.describe Backup::GitalyBackup do let(:parallel_storage) { 3 } it 'passes parallel option through' do - expect(Open3).to receive(:popen2).with(ENV, anything, 'create', '-path', anything, '-parallel-storage', '3').and_call_original + expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything, '-parallel-storage', '3').and_call_original subject.start(:create) subject.wait @@ -90,6 +98,26 @@ RSpec.describe Backup::GitalyBackup do it_behaves_like 'creates a repository backup' end + + context 'custom SSL envs set' do + let(:ssl_env) do + { + 'SSL_CERT_FILE' => '/some/cert/file', + 'SSL_CERT_DIR' => '/some/cert' + } + end + + before do + stub_const('ENV', ssl_env) + end + + it 'passes through SSL envs' do + expect(Open3).to receive(:popen2).with(ssl_env, anything, 'create', '-path', anything).and_call_original + + subject.start(:create) + subject.wait + end + end end context 'restore' do @@ -109,7 +137,7 @@ RSpec.describe Backup::GitalyBackup do copy_bundle_to_backup_path('personal_snippet_repo.bundle', personal_snippet.disk_path + '.bundle') copy_bundle_to_backup_path('project_snippet_repo.bundle', project_snippet.disk_path + '.bundle') - expect(Open3).to receive(:popen2).with(ENV, anything, 'restore', '-path', anything).and_call_original + expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything).and_call_original subject.start(:restore) subject.enqueue(project, Gitlab::GlRepository::PROJECT) @@ -132,7 +160,7 @@ RSpec.describe Backup::GitalyBackup do let(:parallel) { 3 } it 'passes parallel option through' do - expect(Open3).to receive(:popen2).with(ENV, anything, 'restore', '-path', anything, '-parallel', '3').and_call_original + expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything, '-parallel', '3').and_call_original subject.start(:restore) subject.wait @@ -143,7 +171,7 @@ RSpec.describe Backup::GitalyBackup do let(:parallel_storage) { 3 } it 'passes parallel option through' do - expect(Open3).to receive(:popen2).with(ENV, anything, 'restore', '-path', anything, '-parallel-storage', '3').and_call_original + expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything, '-parallel-storage', '3').and_call_original subject.start(:restore) subject.wait diff --git a/spec/lib/banzai/cross_project_reference_spec.rb b/spec/lib/banzai/cross_project_reference_spec.rb index 60ff15a88e0..e703bbc4927 100644 --- a/spec/lib/banzai/cross_project_reference_spec.rb +++ b/spec/lib/banzai/cross_project_reference_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Banzai::CrossProjectReference do let(:including_class) { Class.new.include(described_class).new } - let(:reference_cache) { Banzai::Filter::References::ReferenceCache.new(including_class, {})} + let(:reference_cache) { Banzai::Filter::References::ReferenceCache.new(including_class, {}, {})} before do allow(including_class).to receive(:context).and_return({}) diff --git a/spec/lib/banzai/filter/front_matter_filter_spec.rb b/spec/lib/banzai/filter/front_matter_filter_spec.rb index 3f966c94dd3..cef6a2ddcce 100644 --- a/spec/lib/banzai/filter/front_matter_filter_spec.rb +++ b/spec/lib/banzai/filter/front_matter_filter_spec.rb @@ -39,7 +39,7 @@ RSpec.describe Banzai::Filter::FrontMatterFilter do aggregate_failures do expect(output).not_to include '---' - expect(output).to include "```yaml\nfoo: :foo_symbol\n" + expect(output).to include "```yaml:frontmatter\nfoo: :foo_symbol\n" end end @@ -59,7 +59,7 @@ RSpec.describe Banzai::Filter::FrontMatterFilter do aggregate_failures do expect(output).not_to include '+++' - expect(output).to include "```toml\nfoo = :foo_symbol\n" + expect(output).to include "```toml:frontmatter\nfoo = :foo_symbol\n" end end @@ -81,7 +81,7 @@ RSpec.describe Banzai::Filter::FrontMatterFilter do aggregate_failures do expect(output).not_to include ';;;' - expect(output).to include "```json\n{\n \"foo\": \":foo_symbol\",\n" + expect(output).to include "```json:frontmatter\n{\n \"foo\": \":foo_symbol\",\n" end end @@ -101,7 +101,7 @@ RSpec.describe Banzai::Filter::FrontMatterFilter do aggregate_failures do expect(output).not_to include '---arbitrary' - expect(output).to include "```arbitrary\nfoo = :foo_symbol\n" + expect(output).to include "```arbitrary:frontmatter\nfoo = :foo_symbol\n" end end @@ -130,7 +130,7 @@ RSpec.describe Banzai::Filter::FrontMatterFilter do aggregate_failures do expect(output).to eq <<~MD - ```yaml + ```yaml:frontmatter foo: :foo_symbol bar: :bar_symbol ``` diff --git a/spec/lib/banzai/filter/references/milestone_reference_filter_spec.rb b/spec/lib/banzai/filter/references/milestone_reference_filter_spec.rb index cdf6110dd6c..c21a9339ebb 100644 --- a/spec/lib/banzai/filter/references/milestone_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/references/milestone_reference_filter_spec.rb @@ -437,6 +437,19 @@ RSpec.describe Banzai::Filter::References::MilestoneReferenceFilter do expect(reference_filter(act, context).to_html).to eq exp end end + + context 'when referencing both project and group milestones' do + let(:milestone) { create(:milestone, project: project) } + let(:group_milestone) { create(:milestone, title: 'group_milestone', group: group) } + + it 'links to valid references' do + links = reference_filter("See #{milestone.to_reference(full: true)} and #{group_milestone.to_reference}", context).css('a') + + expect(links.length).to eq(2) + expect(links[0].attr('href')).to eq(urls.milestone_url(milestone)) + expect(links[1].attr('href')).to eq(urls.milestone_url(group_milestone)) + end + end end context 'when milestone is open' do diff --git a/spec/lib/banzai/filter/references/reference_cache_spec.rb b/spec/lib/banzai/filter/references/reference_cache_spec.rb index c9404c381d3..dcd153da16a 100644 --- a/spec/lib/banzai/filter/references/reference_cache_spec.rb +++ b/spec/lib/banzai/filter/references/reference_cache_spec.rb @@ -12,15 +12,48 @@ RSpec.describe Banzai::Filter::References::ReferenceCache do let(:filter_class) { Banzai::Filter::References::IssueReferenceFilter } let(:filter) { filter_class.new(doc, project: project) } - let(:cache) { described_class.new(filter, { project: project }) } + let(:cache) { described_class.new(filter, { project: project }, result) } + let(:result) { {} } describe '#load_references_per_parent' do + subject { cache.load_references_per_parent(filter.nodes) } + it 'loads references grouped per parent paths' do - cache.load_references_per_parent(filter.nodes) + expect(doc).to receive(:to_html).and_call_original + + subject expect(cache.references_per_parent).to eq({ project.full_path => [issue1.iid, issue2.iid].to_set, project2.full_path => [issue3.iid].to_set }) end + + context 'when rendered_html is memoized' do + let(:result) { { rendered_html: 'html' } } + + it 'reuses memoized rendered HTML when available' do + expect(doc).not_to receive(:to_html) + + subject + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(reference_cache_memoization: false) + end + + it 'ignores memoized rendered HTML' do + expect(doc).to receive(:to_html).and_call_original + + subject + end + end + end + + context 'when result is not available' do + let(:result) { nil } + + it { expect { subject }.not_to raise_error } + end end describe '#load_parent_per_reference' do @@ -47,7 +80,7 @@ RSpec.describe Banzai::Filter::References::ReferenceCache do it 'does not have an N+1 query problem with cross projects' do doc_single = Nokogiri::HTML.fragment("#1") filter_single = filter_class.new(doc_single, project: project) - cache_single = described_class.new(filter_single, { project: project }) + cache_single = described_class.new(filter_single, { project: project }, {}) control_count = ActiveRecord::QueryRecorder.new do cache_single.load_references_per_parent(filter_single.nodes) diff --git a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb index 16e30604c99..7e45ecdd135 100644 --- a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb +++ b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb @@ -98,6 +98,14 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter do end end + context "when sourcepos metadata is available" do + it "includes it in the highlighted code block" do + result = filter('<pre data-sourcepos="1:1-3:3"><code lang="plaintext">This is a test</code></pre>') + + expect(result.to_html).to eq('<pre data-sourcepos="1:1-3:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">This is a test</span></code></pre>') + end + end + context "when Rouge lexing fails" do before do allow_next_instance_of(Rouge::Lexers::Ruby) do |instance| diff --git a/spec/lib/banzai/pipeline/full_pipeline_spec.rb b/spec/lib/banzai/pipeline/full_pipeline_spec.rb index 72661003361..7a335fad3f8 100644 --- a/spec/lib/banzai/pipeline/full_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/full_pipeline_spec.rb @@ -34,15 +34,16 @@ RSpec.describe Banzai::Pipeline::FullPipeline do let(:identifier) { html[/.*fnref1-(\d+).*/, 1] } let(:footnote_markdown) do <<~EOF - first[^1] and second[^second] + first[^1] and second[^second] and twenty[^twenty] [^1]: one [^second]: two + [^twenty]: twenty EOF end let(:filtered_footnote) do <<~EOF - <p dir="auto">first<sup class="footnote-ref"><a href="#fn1-#{identifier}" id="fnref1-#{identifier}">1</a></sup> and second<sup class="footnote-ref"><a href="#fn2-#{identifier}" id="fnref2-#{identifier}">2</a></sup></p> + <p dir="auto">first<sup class="footnote-ref"><a href="#fn1-#{identifier}" id="fnref1-#{identifier}">1</a></sup> and second<sup class="footnote-ref"><a href="#fn2-#{identifier}" id="fnref2-#{identifier}">2</a></sup> and twenty<sup class="footnote-ref"><a href="#fn3-#{identifier}" id="fnref3-#{identifier}">3</a></sup></p> <section class="footnotes"><ol> <li id="fn1-#{identifier}"> @@ -51,6 +52,9 @@ RSpec.describe Banzai::Pipeline::FullPipeline do <li id="fn2-#{identifier}"> <p>two <a href="#fnref2-#{identifier}" class="footnote-backref"><gl-emoji title="leftwards arrow with hook" data-name="leftwards_arrow_with_hook" data-unicode-version="1.1">↩</gl-emoji></a></p> </li> + <li id="fn3-#{identifier}"> + <p>twenty <a href="#fnref3-#{identifier}" class="footnote-backref"><gl-emoji title="leftwards arrow with hook" data-name="leftwards_arrow_with_hook" data-unicode-version="1.1">↩</gl-emoji></a></p> + </li> </ol></section> EOF end diff --git a/spec/lib/banzai/pipeline/pre_process_pipeline_spec.rb b/spec/lib/banzai/pipeline/pre_process_pipeline_spec.rb index c628d8d5b41..5021ef3a79a 100644 --- a/spec/lib/banzai/pipeline/pre_process_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/pre_process_pipeline_spec.rb @@ -20,7 +20,7 @@ RSpec.describe Banzai::Pipeline::PreProcessPipeline do aggregate_failures do expect(result[:output]).not_to include "\xEF\xBB\xBF" expect(result[:output]).not_to include '---' - expect(result[:output]).to include "```yaml\nfoo: :foo_symbol\n" + expect(result[:output]).to include "```yaml:frontmatter\nfoo: :foo_symbol\n" expect(result[:output]).to include "> blockquote\n" end end diff --git a/spec/lib/bulk_imports/clients/graphql_spec.rb b/spec/lib/bulk_imports/clients/graphql_spec.rb index 2f212458c4a..a5b5e96e594 100644 --- a/spec/lib/bulk_imports/clients/graphql_spec.rb +++ b/spec/lib/bulk_imports/clients/graphql_spec.rb @@ -34,7 +34,7 @@ RSpec.describe BulkImports::Clients::Graphql do let(:version) { '13.0.0' } it 'raises an error' do - expect { subject.execute('test') }.to raise_error(::BulkImports::Error, "Unsupported GitLab Version. Minimum Supported Gitlab Version #{BulkImport::MINIMUM_GITLAB_MAJOR_VERSION}.") + expect { subject.execute('test') }.to raise_error(::BulkImports::Error, "Unsupported GitLab Version. Minimum Supported Gitlab Version #{BulkImport::MIN_MAJOR_VERSION}.") end end end diff --git a/spec/lib/bulk_imports/clients/http_spec.rb b/spec/lib/bulk_imports/clients/http_spec.rb index c36cb80851a..623f9aa453a 100644 --- a/spec/lib/bulk_imports/clients/http_spec.rb +++ b/spec/lib/bulk_imports/clients/http_spec.rb @@ -8,7 +8,7 @@ RSpec.describe BulkImports::Clients::HTTP do let(:url) { 'http://gitlab.example' } let(:token) { 'token' } let(:resource) { 'resource' } - let(:version) { "#{BulkImport::MINIMUM_GITLAB_MAJOR_VERSION}.0.0" } + let(:version) { "#{BulkImport::MIN_MAJOR_VERSION}.0.0" } let(:response_double) { double(code: 200, success?: true, parsed_response: {}) } let(:version_response) { double(code: 200, success?: true, parsed_response: { 'version' => version }) } @@ -32,7 +32,7 @@ RSpec.describe BulkImports::Clients::HTTP do it 'raises BulkImports::Error' do allow(Gitlab::HTTP).to receive(method).and_raise(Errno::ECONNREFUSED) - expect { subject.public_send(method, resource) }.to raise_exception(BulkImports::Error) + expect { subject.public_send(method, resource) }.to raise_exception(BulkImports::NetworkError) end end @@ -42,7 +42,7 @@ RSpec.describe BulkImports::Clients::HTTP do allow(Gitlab::HTTP).to receive(method).and_return(response_double) - expect { subject.public_send(method, resource) }.to raise_exception(BulkImports::Error) + expect { subject.public_send(method, resource) }.to raise_exception(BulkImports::NetworkError) end end end @@ -176,11 +176,37 @@ RSpec.describe BulkImports::Clients::HTTP do end end + describe '#instance_version' do + it 'returns version as an instance of Gitlab::VersionInfo' do + expect(subject.instance_version).to eq(Gitlab::VersionInfo.parse(version)) + end + end + + describe '#compatible_for_project_migration?' do + context 'when instance version is lower the the expected minimum' do + it 'returns false' do + expect(subject.compatible_for_project_migration?).to be false + end + end + + context 'when instance version is at least the expected minimum' do + let(:version) { "14.4.4" } + + it 'returns true' do + expect(subject.compatible_for_project_migration?).to be true + end + end + end + context 'when source instance is incompatible' do let(:version) { '13.0.0' } it 'raises an error' do - expect { subject.get(resource) }.to raise_error(::BulkImports::Error, "Unsupported GitLab Version. Minimum Supported Gitlab Version #{BulkImport::MINIMUM_GITLAB_MAJOR_VERSION}.") + expect { subject.get(resource) } + .to raise_error( + ::BulkImports::Error, + "Unsupported GitLab Version. Minimum Supported Gitlab Version #{BulkImport::MIN_MAJOR_VERSION}." + ) end end diff --git a/spec/lib/bulk_imports/common/pipelines/boards_pipeline_spec.rb b/spec/lib/bulk_imports/common/pipelines/boards_pipeline_spec.rb new file mode 100644 index 00000000000..241bd694a2c --- /dev/null +++ b/spec/lib/bulk_imports/common/pipelines/boards_pipeline_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Common::Pipelines::BoardsPipeline do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:bulk_import) { create(:bulk_import, user: user) } + + let(:board_data) do + { + "name" => "Test Board", + "lists" => [ + { + "list_type" => "backlog", + "position" => 0 + }, + { + "list_type" => "closed", + "position" => 1 + }, + { + "list_type" => "label", + "position" => 2, + "label" => { + "title" => "test", + "type" => "GroupLabel", + "group_id" => group.id + } + } + ] + } + end + + let(:tracker) { create(:bulk_import_tracker, entity: entity) } + let(:context) { BulkImports::Pipeline::Context.new(tracker) } + + subject { described_class.new(context) } + + before do + allow_next_instance_of(BulkImports::Common::Extractors::NdjsonExtractor) do |extractor| + allow(extractor).to receive(:extract).and_return(BulkImports::Pipeline::ExtractedData.new(data: board_data)) + end + group.add_owner(user) + end + + context 'when issue board belongs to a project' do + let_it_be(:entity) do + create( + :bulk_import_entity, + source_type: :project_entity, + project: project, + bulk_import: bulk_import, + source_full_path: 'source/full/path', + destination_name: 'My Destination Group', + destination_namespace: group.full_path + ) + end + + describe '#run' do + it 'imports issue boards into destination project' do + expect { subject.run }.to change(::Board, :count).by(1) + board = project.boards.find_by(name: board_data["name"]) + expect(board).to be + expect(board.project.id).to eq(project.id) + expect(board.lists.count).to eq(3) + expect(board.lists.map(&:list_type).sort).to match_array(%w(backlog closed label)) + expect(board.lists.find_by(list_type: "label").label.title).to eq("test") + end + end + end + + context 'when issue board belongs to a group' do + let_it_be(:entity) do + create( + :bulk_import_entity, + group: group, + bulk_import: bulk_import, + source_full_path: 'source/full/path', + destination_name: 'My Destination Group', + destination_namespace: group.full_path + ) + end + + describe '#run' do + it 'imports issue boards into destination group' do + expect { subject.run }.to change(::Board, :count).by(1) + board = group.boards.find_by(name: board_data["name"]) + expect(board).to be + expect(board.group.id).to eq(group.id) + expect(board.lists.count).to eq(3) + expect(board.lists.map(&:list_type).sort).to match_array(%w(backlog closed label)) + expect(board.lists.find_by(list_type: "label").label.title).to eq("test") + end + end + end +end diff --git a/spec/lib/bulk_imports/groups/pipelines/labels_pipeline_spec.rb b/spec/lib/bulk_imports/common/pipelines/labels_pipeline_spec.rb index 6344dae0fb7..9e3a6d5b8df 100644 --- a/spec/lib/bulk_imports/groups/pipelines/labels_pipeline_spec.rb +++ b/spec/lib/bulk_imports/common/pipelines/labels_pipeline_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe BulkImports::Groups::Pipelines::LabelsPipeline do +RSpec.describe BulkImports::Common::Pipelines::LabelsPipeline do let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group) } let_it_be(:bulk_import) { create(:bulk_import, user: user) } diff --git a/spec/lib/bulk_imports/groups/pipelines/boards_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/boards_pipeline_spec.rb deleted file mode 100644 index 8b2f03ca15f..00000000000 --- a/spec/lib/bulk_imports/groups/pipelines/boards_pipeline_spec.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe BulkImports::Groups::Pipelines::BoardsPipeline do - let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } - let_it_be(:bulk_import) { create(:bulk_import, user: user) } - let_it_be(:filepath) { 'spec/fixtures/bulk_imports/gz/boards.ndjson.gz' } - let_it_be(:entity) do - create( - :bulk_import_entity, - group: group, - bulk_import: bulk_import, - source_full_path: 'source/full/path', - destination_name: 'My Destination Group', - destination_namespace: group.full_path - ) - end - - let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) } - let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) } - - let(:tmpdir) { Dir.mktmpdir } - - before do - FileUtils.copy_file(filepath, File.join(tmpdir, 'boards.ndjson.gz')) - group.add_owner(user) - end - - subject { described_class.new(context) } - - describe '#run' do - it 'imports group boards into destination group and removes tmpdir' do - allow(Dir).to receive(:mktmpdir).and_return(tmpdir) - allow_next_instance_of(BulkImports::FileDownloadService) do |service| - allow(service).to receive(:execute) - end - - expect { subject.run }.to change(Board, :count).by(1) - - lists = group.boards.find_by(name: 'first board').lists - - expect(lists.count).to eq(3) - expect(lists.first.label.title).to eq('TSL') - expect(lists.second.label.title).to eq('Sosync') - end - end -end diff --git a/spec/lib/bulk_imports/groups/stage_spec.rb b/spec/lib/bulk_imports/groups/stage_spec.rb index 81c0ffc14d4..b322b7b0edf 100644 --- a/spec/lib/bulk_imports/groups/stage_spec.rb +++ b/spec/lib/bulk_imports/groups/stage_spec.rb @@ -3,36 +3,42 @@ require 'spec_helper' RSpec.describe BulkImports::Groups::Stage do + let(:bulk_import) { build(:bulk_import) } + let(:pipelines) do [ [0, BulkImports::Groups::Pipelines::GroupPipeline], [1, BulkImports::Groups::Pipelines::GroupAvatarPipeline], [1, BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline], [1, BulkImports::Groups::Pipelines::MembersPipeline], - [1, BulkImports::Groups::Pipelines::LabelsPipeline], + [1, BulkImports::Common::Pipelines::LabelsPipeline], [1, BulkImports::Groups::Pipelines::MilestonesPipeline], [1, BulkImports::Groups::Pipelines::BadgesPipeline], - [2, BulkImports::Groups::Pipelines::BoardsPipeline] + [2, BulkImports::Common::Pipelines::BoardsPipeline] ] end + it 'raises error when initialized without a BulkImport' do + expect { described_class.new({}) }.to raise_error(ArgumentError, 'Expected an argument of type ::BulkImport') + end + describe '.pipelines' do it 'list all the pipelines with their stage number, ordered by stage' do - expect(described_class.pipelines & pipelines).to eq(pipelines) - expect(described_class.pipelines.last.last).to eq(BulkImports::Common::Pipelines::EntityFinisher) + expect(described_class.new(bulk_import).pipelines & pipelines).to eq(pipelines) + expect(described_class.new(bulk_import).pipelines.last.last).to eq(BulkImports::Common::Pipelines::EntityFinisher) end it 'includes project entities pipeline' do stub_feature_flags(bulk_import_projects: true) - expect(described_class.pipelines).to include([1, BulkImports::Groups::Pipelines::ProjectEntitiesPipeline]) + expect(described_class.new(bulk_import).pipelines).to include([1, BulkImports::Groups::Pipelines::ProjectEntitiesPipeline]) end context 'when bulk_import_projects feature flag is disabled' do it 'does not include project entities pipeline' do stub_feature_flags(bulk_import_projects: false) - expect(described_class.pipelines.flatten).not_to include(BulkImports::Groups::Pipelines::ProjectEntitiesPipeline) + expect(described_class.new(bulk_import).pipelines.flatten).not_to include(BulkImports::Groups::Pipelines::ProjectEntitiesPipeline) end end end diff --git a/spec/lib/bulk_imports/ndjson_pipeline_spec.rb b/spec/lib/bulk_imports/ndjson_pipeline_spec.rb index 57a258b0d9f..7d156c2c3df 100644 --- a/spec/lib/bulk_imports/ndjson_pipeline_spec.rb +++ b/spec/lib/bulk_imports/ndjson_pipeline_spec.rb @@ -186,4 +186,20 @@ RSpec.describe BulkImports::NdjsonPipeline do end end end + + describe '#relation_factory' do + context 'when portable is group' do + it 'returns group relation factory' do + expect(subject.relation_factory).to eq(Gitlab::ImportExport::Group::RelationFactory) + end + end + + context 'when portable is project' do + subject { NdjsonPipelineClass.new(project, user) } + + it 'returns project relation factory' do + expect(subject.relation_factory).to eq(Gitlab::ImportExport::Project::RelationFactory) + end + end + end end diff --git a/spec/lib/bulk_imports/network_error_spec.rb b/spec/lib/bulk_imports/network_error_spec.rb new file mode 100644 index 00000000000..11f555fee09 --- /dev/null +++ b/spec/lib/bulk_imports/network_error_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::NetworkError, :clean_gitlab_redis_cache do + let(:tracker) { double(id: 1, stage: 2, entity: double(id: 3)) } + + describe '.new' do + it 'requires either a message or a HTTP response' do + expect { described_class.new } + .to raise_error(ArgumentError, 'message or response required') + end + end + + describe '#retriable?' do + it 'returns true for MAX_RETRIABLE_COUNT times when cause if one of RETRIABLE_EXCEPTIONS' do + raise described_class::RETRIABLE_EXCEPTIONS.sample + rescue StandardError => cause + begin + raise described_class, cause + rescue StandardError => exception + described_class::MAX_RETRIABLE_COUNT.times do + expect(exception.retriable?(tracker)).to eq(true) + end + + expect(exception.retriable?(tracker)).to eq(false) + end + end + + it 'returns true for MAX_RETRIABLE_COUNT times when response is one of RETRIABLE_CODES' do + exception = described_class.new(response: double(code: 429)) + + described_class::MAX_RETRIABLE_COUNT.times do + expect(exception.retriable?(tracker)).to eq(true) + end + + expect(exception.retriable?(tracker)).to eq(false) + end + + it 'returns false for other exceptions' do + raise StandardError + rescue StandardError => cause + begin + raise described_class, cause + rescue StandardError => exception + expect(exception.retriable?(tracker)).to eq(false) + end + end + end + + describe '#retry_delay' do + it 'returns the default value when there is not a rate limit error' do + exception = described_class.new('foo') + + expect(exception.retry_delay).to eq(described_class::DEFAULT_RETRY_DELAY_SECONDS.seconds) + end + + context 'when the exception is a rate limit error' do + it 'returns the "Retry-After"' do + exception = described_class.new(response: double(code: 429, headers: { 'Retry-After' => 20 })) + + expect(exception.retry_delay).to eq(20.seconds) + end + + it 'returns the default value when there is no "Retry-After" header' do + exception = described_class.new(response: double(code: 429, headers: {})) + + expect(exception.retry_delay).to eq(described_class::DEFAULT_RETRY_DELAY_SECONDS.seconds) + end + end + end +end diff --git a/spec/lib/bulk_imports/projects/graphql/get_repository_query_spec.rb b/spec/lib/bulk_imports/projects/graphql/get_repository_query_spec.rb new file mode 100644 index 00000000000..4dba81dc0d2 --- /dev/null +++ b/spec/lib/bulk_imports/projects/graphql/get_repository_query_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Projects::Graphql::GetRepositoryQuery do + describe 'query repository based on full_path' do + let(:entity) { double(source_full_path: 'test', bulk_import: nil) } + let(:tracker) { double(entity: entity) } + let(:context) { BulkImports::Pipeline::Context.new(tracker) } + + it 'returns project repository url' do + expect(described_class.to_s).to include('httpUrlToRepo') + end + + it 'queries project based on source_full_path' do + expected = { full_path: entity.source_full_path } + + expect(described_class.variables(context)).to eq(expected) + end + end +end diff --git a/spec/lib/bulk_imports/projects/pipelines/issues_pipeline_spec.rb b/spec/lib/bulk_imports/projects/pipelines/issues_pipeline_spec.rb new file mode 100644 index 00000000000..97fcddefd42 --- /dev/null +++ b/spec/lib/bulk_imports/projects/pipelines/issues_pipeline_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Projects::Pipelines::IssuesPipeline do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:bulk_import) { create(:bulk_import, user: user) } + let_it_be(:entity) do + create( + :bulk_import_entity, + :project_entity, + project: project, + bulk_import: bulk_import, + source_full_path: 'source/full/path', + destination_name: 'My Destination Project', + destination_namespace: group.full_path + ) + end + + let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) } + let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) } + + let(:issue_attributes) { {} } + let(:issue) do + { + 'iid' => 7, + 'title' => 'Imported Issue', + 'description' => 'Description', + 'state' => 'opened', + 'updated_at' => '2016-06-14T15:02:47.967Z', + 'author_id' => 22 + }.merge(issue_attributes) + end + + subject(:pipeline) { described_class.new(context) } + + describe '#run' do + before do + group.add_owner(user) + issue_with_index = [issue, 0] + + allow_next_instance_of(BulkImports::Common::Extractors::NdjsonExtractor) do |extractor| + allow(extractor).to receive(:extract).and_return(BulkImports::Pipeline::ExtractedData.new(data: [issue_with_index])) + end + + pipeline.run + end + + it 'imports issue into destination project' do + expect(project.issues.count).to eq(1) + + imported_issue = project.issues.last + + aggregate_failures do + expect(imported_issue.iid).to eq(7) + expect(imported_issue.title).to eq(issue['title']) + expect(imported_issue.description).to eq(issue['description']) + expect(imported_issue.author).to eq(user) + expect(imported_issue.state).to eq('opened') + expect(imported_issue.updated_at.to_s).to eq('2016-06-14 15:02:47 UTC') + end + end + + context 'zoom meetings' do + let(:issue_attributes) { { 'zoom_meetings' => [{ 'url' => 'https://zoom.us/j/123456789' }] } } + + it 'restores zoom meetings' do + expect(project.issues.last.zoom_meetings.first.url).to eq('https://zoom.us/j/123456789') + end + end + + context 'sentry issue' do + let(:issue_attributes) { { 'sentry_issue' => { 'sentry_issue_identifier' => '1234567891' } } } + + it 'restores sentry issue information' do + expect(project.issues.last.sentry_issue.sentry_issue_identifier).to eq(1234567891) + end + end + + context 'award emoji' do + let(:issue_attributes) { { 'award_emoji' => [{ 'name' => 'musical_keyboard', 'user_id' => 22 }] } } + + it 'has award emoji on an issue' do + award_emoji = project.issues.last.award_emoji.first + + expect(award_emoji.name).to eq('musical_keyboard') + expect(award_emoji.user).to eq(user) + end + end + context 'issue state' do + let(:issue_attributes) { { 'state' => 'closed' } } + + it 'restores issue state' do + expect(project.issues.last.state).to eq('closed') + end + end + + context 'labels' do + let(:issue_attributes) do + { + 'label_links' => [ + { 'label' => { 'title' => 'imported label 1', 'type' => 'ProjectLabel' } }, + { 'label' => { 'title' => 'imported label 2', 'type' => 'ProjectLabel' } } + ] + } + end + + it 'restores issue labels' do + expect(project.issues.last.labels.pluck(:title)).to contain_exactly('imported label 1', 'imported label 2') + end + end + + context 'milestone' do + let(:issue_attributes) { { 'milestone' => { 'title' => 'imported milestone' } } } + + it 'restores issue milestone' do + expect(project.issues.last.milestone.title).to eq('imported milestone') + end + end + + context 'timelogs' do + let(:issue_attributes) { { 'timelogs' => [{ 'time_spent' => 72000, 'spent_at' => '2019-12-27T00:00:00.000Z', 'user_id' => 22 }] } } + + it 'restores issue timelogs' do + timelog = project.issues.last.timelogs.first + + aggregate_failures do + expect(timelog.time_spent).to eq(72000) + expect(timelog.spent_at).to eq("2019-12-27T00:00:00.000Z") + end + end + end + + context 'notes' do + let(:issue_attributes) do + { + 'notes' => [ + { + 'note' => 'Issue note', + 'author_id' => 22, + 'author' => { + 'name' => 'User 22' + }, + 'updated_at' => '2016-06-14T15:02:47.770Z', + 'award_emoji' => [ + { + 'name' => 'clapper', + 'user_id' => 22 + } + ] + } + ] + } + end + + it 'restores issue notes and their award emoji' do + note = project.issues.last.notes.first + + aggregate_failures do + expect(note.note).to eq("Issue note\n\n *By User 22 on 2016-06-14T15:02:47 (imported from GitLab)*") + expect(note.award_emoji.first.name).to eq('clapper') + end + end + end + end +end diff --git a/spec/lib/bulk_imports/projects/pipelines/repository_pipeline_spec.rb b/spec/lib/bulk_imports/projects/pipelines/repository_pipeline_spec.rb new file mode 100644 index 00000000000..af39ec7a11c --- /dev/null +++ b/spec/lib/bulk_imports/projects/pipelines/repository_pipeline_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Projects::Pipelines::RepositoryPipeline do + describe '#run' do + let_it_be(:user) { create(:user) } + let_it_be(:parent) { create(:project) } + let_it_be(:bulk_import) { create(:bulk_import, user: user) } + let_it_be(:bulk_import_configuration) { create(:bulk_import_configuration, bulk_import: bulk_import) } + + let_it_be(:entity) do + create( + :bulk_import_entity, + :project_entity, + bulk_import: bulk_import, + source_full_path: 'source/full/path', + destination_name: 'My Destination Repository', + destination_namespace: parent.full_path, + project: parent + ) + end + + let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) } + let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) } + + context 'successfully imports repository' do + let(:project_data) do + { + 'httpUrlToRepo' => 'http://test.git' + } + end + + subject { described_class.new(context) } + + it 'imports new repository into destination project' do + allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor| + allow(extractor).to receive(:extract).and_return(BulkImports::Pipeline::ExtractedData.new(data: project_data)) + end + + expect_next_instance_of(Gitlab::GitalyClient::RepositoryService) do |repository_service| + url = project_data['httpUrlToRepo'].sub("://", "://oauth2:#{bulk_import_configuration.access_token}@") + expect(repository_service).to receive(:import_repository).with(url).and_return 0 + end + + subject.run + end + end + + context 'blocked local networks' do + let(:project_data) do + { + 'httpUrlToRepo' => 'http://localhost/foo.git' + } + end + + before do + allow(Gitlab.config.gitlab).to receive(:host).and_return('notlocalhost.gitlab.com') + allow(Gitlab::CurrentSettings).to receive(:allow_local_requests_from_web_hooks_and_services?).and_return(false) + allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor| + allow(extractor).to receive(:extract).and_return(BulkImports::Pipeline::ExtractedData.new(data: project_data)) + end + end + + subject { described_class.new(context) } + + it 'imports new repository into destination project' do + subject.run + expect(context.entity.failed?).to be_truthy + end + end + end +end diff --git a/spec/lib/bulk_imports/projects/stage_spec.rb b/spec/lib/bulk_imports/projects/stage_spec.rb index 428812a34ef..c606cf7c556 100644 --- a/spec/lib/bulk_imports/projects/stage_spec.rb +++ b/spec/lib/bulk_imports/projects/stage_spec.rb @@ -6,13 +6,23 @@ RSpec.describe BulkImports::Projects::Stage do let(:pipelines) do [ [0, BulkImports::Projects::Pipelines::ProjectPipeline], - [1, BulkImports::Common::Pipelines::EntityFinisher] + [1, BulkImports::Projects::Pipelines::RepositoryPipeline], + [2, BulkImports::Common::Pipelines::LabelsPipeline], + [3, BulkImports::Projects::Pipelines::IssuesPipeline], + [4, BulkImports::Common::Pipelines::BoardsPipeline], + [5, BulkImports::Common::Pipelines::EntityFinisher] ] end - describe '.pipelines' do + subject do + bulk_import = build(:bulk_import) + + described_class.new(bulk_import) + end + + describe '#pipelines' do it 'list all the pipelines with their stage number, ordered by stage' do - expect(described_class.pipelines).to eq(pipelines) + expect(subject.pipelines).to eq(pipelines) end end end diff --git a/spec/lib/container_registry/client_spec.rb b/spec/lib/container_registry/client_spec.rb index 9d6f4db537d..47a8fcf5dd0 100644 --- a/spec/lib/container_registry/client_spec.rb +++ b/spec/lib/container_registry/client_spec.rb @@ -111,6 +111,49 @@ RSpec.describe ContainerRegistry::Client do it_behaves_like 'handling timeouts' end + shared_examples 'handling repository info' do + context 'when the check is successful' do + context 'when using the GitLab container registry' do + before do + stub_registry_info(headers: { + 'GitLab-Container-Registry-Version' => '2.9.1-gitlab', + 'GitLab-Container-Registry-Features' => 'a,b,c' + }) + end + + it 'identifies the vendor as "gitlab"' do + expect(subject).to include(vendor: 'gitlab') + end + + it 'identifies version and features' do + expect(subject).to include(version: '2.9.1-gitlab', features: %w[a b c]) + end + end + + context 'when using a third-party container registry' do + before do + stub_registry_info + end + + it 'identifies the vendor as "other"' do + expect(subject).to include(vendor: 'other') + end + + it 'does not identify version or features' do + expect(subject).to include(version: nil, features: []) + end + end + end + + context 'when the check is not successful' do + it 'does not identify vendor, version or features' do + stub_registry_info(status: 500) + + expect(subject).to eq({}) + end + end + end + describe '#repository_manifest' do subject { client.repository_manifest('group/test', 'mytag') } @@ -316,46 +359,7 @@ RSpec.describe ContainerRegistry::Client do describe '#registry_info' do subject { client.registry_info } - context 'when the check is successful' do - context 'when using the GitLab container registry' do - before do - stub_registry_info(headers: { - 'GitLab-Container-Registry-Version' => '2.9.1-gitlab', - 'GitLab-Container-Registry-Features' => 'a,b,c' - }) - end - - it 'identifies the vendor as "gitlab"' do - expect(subject).to include(vendor: 'gitlab') - end - - it 'identifies version and features' do - expect(subject).to include(version: '2.9.1-gitlab', features: %w[a b c]) - end - end - - context 'when using a third-party container registry' do - before do - stub_registry_info - end - - it 'identifies the vendor as "other"' do - expect(subject).to include(vendor: 'other') - end - - it 'does not identify version or features' do - expect(subject).to include(version: nil, features: []) - end - end - end - - context 'when the check is not successful' do - it 'does not identify vendor, version or features' do - stub_registry_info(status: 500) - - expect(subject).to eq({}) - end - end + it_behaves_like 'handling repository info' end describe '.supports_tag_delete?' do @@ -418,6 +422,16 @@ RSpec.describe ContainerRegistry::Client do end end + describe '.registry_info' do + subject { described_class.registry_info } + + before do + stub_container_registry_config(enabled: true, api_url: registry_api_url, key: 'spec/fixtures/x509_certificate_pk.key') + end + + it_behaves_like 'handling repository info' + end + def stub_upload(path, content, digest, status = 200) stub_request(:post, "#{registry_api_url}/v2/#{path}/blobs/uploads/") .with(headers: headers_with_accept_types) diff --git a/spec/lib/container_registry/tag_spec.rb b/spec/lib/container_registry/tag_spec.rb index d696b61ac9d..d6e6b254dd9 100644 --- a/spec/lib/container_registry/tag_spec.rb +++ b/spec/lib/container_registry/tag_spec.rb @@ -60,6 +60,20 @@ RSpec.describe ContainerRegistry::Tag do end context 'manifest processing' do + shared_examples 'using the value manually set on created_at' do + let(:value) { 5.seconds.ago } + + before do + tag.created_at = value + end + + it 'does not use the config' do + expect(tag).not_to receive(:config) + + expect(subject).to eq(value) + end + end + context 'schema v1' do before do stub_request(:get, 'http://registry.gitlab/v2/group/test/manifests/tag') @@ -93,6 +107,8 @@ RSpec.describe ContainerRegistry::Tag do subject { tag.created_at } it { is_expected.to be_nil } + + it_behaves_like 'using the value manually set on created_at' end end end @@ -117,6 +133,8 @@ RSpec.describe ContainerRegistry::Tag do subject { tag.created_at } it { is_expected.to be_nil } + + it_behaves_like 'using the value manually set on created_at' end end @@ -154,6 +172,8 @@ RSpec.describe ContainerRegistry::Tag do subject { tag.created_at } it { is_expected.not_to be_nil } + + it_behaves_like 'using the value manually set on created_at' end end diff --git a/spec/lib/error_tracking/sentry_client/issue_spec.rb b/spec/lib/error_tracking/sentry_client/issue_spec.rb index e54296c58e0..82db0f70f2e 100644 --- a/spec/lib/error_tracking/sentry_client/issue_spec.rb +++ b/spec/lib/error_tracking/sentry_client/issue_spec.rb @@ -257,6 +257,10 @@ RSpec.describe ErrorTracking::SentryClient::Issue do expect(subject.gitlab_issue).to eq('https://gitlab.com/gitlab-org/gitlab/issues/1') end + it 'has an integrated attribute set to false' do + expect(subject.integrated).to be_falsey + end + context 'when issue annotations exist' do before do issue_sample_response['annotations'] = [ diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/code_stage_start_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/code_stage_start_spec.rb index b6f9c8106c9..2e96fd09602 100644 --- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/code_stage_start_spec.rb +++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/code_stage_start_spec.rb @@ -19,4 +19,16 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::CodeStageStart do expect(records).to eq([merge_request]) expect(records).not_to include(other_merge_request) end + + it_behaves_like 'LEFT JOIN-able value stream analytics event' do + let_it_be(:record_with_data) do + mr_closing_issue = FactoryBot.create(:merge_requests_closing_issues) + issue = mr_closing_issue.issue + issue.metrics.update!(first_mentioned_in_commit_at: Time.current) + + mr_closing_issue.merge_request + end + + let_it_be(:record_without_data) { create(:merge_request) } + end end diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_created_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_created_spec.rb index 224a18653ed..3f50dd38322 100644 --- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_created_spec.rb +++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_created_spec.rb @@ -4,4 +4,8 @@ require 'spec_helper' RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::IssueCreated do it_behaves_like 'value stream analytics event' + + it_behaves_like 'LEFT JOIN-able value stream analytics event' do + let_it_be(:record_with_data) { create(:issue) } + end end diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_deployed_to_production_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_deployed_to_production_spec.rb index 93e588675d3..e807565ecb5 100644 --- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_deployed_to_production_spec.rb +++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_deployed_to_production_spec.rb @@ -4,4 +4,16 @@ require 'spec_helper' RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::IssueDeployedToProduction do it_behaves_like 'value stream analytics event' + + it_behaves_like 'LEFT JOIN-able value stream analytics event' do + let_it_be(:record_with_data) do + mr_closing_issue = FactoryBot.create(:merge_requests_closing_issues) + mr = mr_closing_issue.merge_request + mr.metrics.update!(first_deployed_to_production_at: Time.current) + + mr_closing_issue.issue + end + + let_it_be(:record_without_data) { create(:issue) } + end end diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_first_mentioned_in_commit_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_first_mentioned_in_commit_spec.rb index bc0e388cf53..9bb023f9fdc 100644 --- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_first_mentioned_in_commit_spec.rb +++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_first_mentioned_in_commit_spec.rb @@ -4,4 +4,9 @@ require 'spec_helper' RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::IssueFirstMentionedInCommit do it_behaves_like 'value stream analytics event' + + it_behaves_like 'LEFT JOIN-able value stream analytics event' do + let_it_be(:record_with_data) { create(:issue).tap { |i| i.metrics.update!(first_mentioned_in_commit_at: Time.current) } } + let_it_be(:record_without_data) { create(:issue) } + end end diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_stage_end_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_stage_end_spec.rb index ddc5f015a8c..7b46a86cbe2 100644 --- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_stage_end_spec.rb +++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_stage_end_spec.rb @@ -4,4 +4,9 @@ require 'spec_helper' RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::IssueStageEnd do it_behaves_like 'value stream analytics event' + + it_behaves_like 'LEFT JOIN-able value stream analytics event' do + let_it_be(:record_with_data) { create(:issue).tap { |i| i.metrics.update!(first_added_to_board_at: Time.current) } } + let_it_be(:record_without_data) { create(:issue) } + end end diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_created_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_created_spec.rb index 281cc31c9e0..1139f9099cb 100644 --- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_created_spec.rb +++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_created_spec.rb @@ -4,4 +4,8 @@ require 'spec_helper' RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestCreated do it_behaves_like 'value stream analytics event' + + it_behaves_like 'LEFT JOIN-able value stream analytics event' do + let_it_be(:record_with_data) { create(:merge_request) } + end end diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_first_deployed_to_production_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_first_deployed_to_production_spec.rb index e1dd2e56e2b..a62facb6974 100644 --- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_first_deployed_to_production_spec.rb +++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_first_deployed_to_production_spec.rb @@ -4,4 +4,9 @@ require 'spec_helper' RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestFirstDeployedToProduction do it_behaves_like 'value stream analytics event' + + it_behaves_like 'LEFT JOIN-able value stream analytics event' do + let_it_be(:record_with_data) { create(:merge_request).tap { |mr| mr.metrics.update!(first_deployed_to_production_at: Time.current) } } + let_it_be(:record_without_data) { create(:merge_request) } + end end diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_finished_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_finished_spec.rb index 51324966f26..c5cfe43895e 100644 --- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_finished_spec.rb +++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_finished_spec.rb @@ -4,4 +4,9 @@ require 'spec_helper' RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastBuildFinished do it_behaves_like 'value stream analytics event' + + it_behaves_like 'LEFT JOIN-able value stream analytics event' do + let_it_be(:record_with_data) { create(:merge_request).tap { |mr| mr.metrics.update!(latest_build_finished_at: Time.current) } } + let_it_be(:record_without_data) { create(:merge_request) } + end end diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_started_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_started_spec.rb index 10dcaf23b81..6f8a82a9ae5 100644 --- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_started_spec.rb +++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_started_spec.rb @@ -4,4 +4,9 @@ require 'spec_helper' RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastBuildStarted do it_behaves_like 'value stream analytics event' + + it_behaves_like 'LEFT JOIN-able value stream analytics event' do + let_it_be(:record_with_data) { create(:merge_request).tap { |mr| mr.metrics.update!(latest_build_started_at: Time.current) } } + let_it_be(:record_without_data) { create(:merge_request) } + end end diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_merged_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_merged_spec.rb index 6e20eb73ed9..0060ed0fd48 100644 --- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_merged_spec.rb +++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_merged_spec.rb @@ -4,4 +4,9 @@ require 'spec_helper' RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestMerged do it_behaves_like 'value stream analytics event' + + it_behaves_like 'LEFT JOIN-able value stream analytics event' do + let_it_be(:record_with_data) { create(:merge_request).tap { |mr| mr.metrics.update!(merged_at: Time.current) } } + let_it_be(:record_without_data) { create(:merge_request) } + end end diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/plan_stage_start_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/plan_stage_start_spec.rb index b8c68003127..379d59e4c5e 100644 --- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/plan_stage_start_spec.rb +++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/plan_stage_start_spec.rb @@ -21,4 +21,9 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::PlanStageStart do expect(records).to match_array([issue1, issue2]) expect(records).not_to include(issue_without_metrics) end + + it_behaves_like 'LEFT JOIN-able value stream analytics event' do + let_it_be(:record_with_data) { create(:issue).tap { |i| i.metrics.update!(first_added_to_board_at: Time.current) } } + let_it_be(:record_without_data) { create(:issue) } + end end diff --git a/spec/lib/gitlab/application_rate_limiter_spec.rb b/spec/lib/gitlab/application_rate_limiter_spec.rb index 2525b1ce41e..0fb99688d27 100644 --- a/spec/lib/gitlab/application_rate_limiter_spec.rb +++ b/spec/lib/gitlab/application_rate_limiter_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::ApplicationRateLimiter, :clean_gitlab_redis_cache do +RSpec.describe Gitlab::ApplicationRateLimiter do let(:redis) { double('redis') } let(:user) { create(:user) } let(:project) { create(:project) } @@ -20,7 +20,7 @@ RSpec.describe Gitlab::ApplicationRateLimiter, :clean_gitlab_redis_cache do subject { described_class } before do - allow(Gitlab::Redis::Cache).to receive(:with).and_yield(redis) + allow(Gitlab::Redis::RateLimiting).to receive(:with).and_yield(redis) allow(described_class).to receive(:rate_limits).and_return(rate_limits) end @@ -106,9 +106,9 @@ RSpec.describe Gitlab::ApplicationRateLimiter, :clean_gitlab_redis_cache do let(:attributes) do base_attributes.merge({ - user_id: current_user.id, - username: current_user.username - }) + user_id: current_user.id, + username: current_user.username + }) end it 'logs information to auth.log' do diff --git a/spec/lib/gitlab/auth/request_authenticator_spec.rb b/spec/lib/gitlab/auth/request_authenticator_spec.rb index 2543eb3a5e9..6f3d6187076 100644 --- a/spec/lib/gitlab/auth/request_authenticator_spec.rb +++ b/spec/lib/gitlab/auth/request_authenticator_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe Gitlab::Auth::RequestAuthenticator do + include DependencyProxyHelpers + let(:env) do { 'rack.input' => '', @@ -15,8 +17,8 @@ RSpec.describe Gitlab::Auth::RequestAuthenticator do subject { described_class.new(request) } describe '#user' do - let!(:sessionless_user) { build(:user) } - let!(:session_user) { build(:user) } + let_it_be(:sessionless_user) { build(:user) } + let_it_be(:session_user) { build(:user) } it 'returns sessionless user first' do allow_any_instance_of(described_class).to receive(:find_sessionless_user).and_return(sessionless_user) @@ -41,15 +43,25 @@ RSpec.describe Gitlab::Auth::RequestAuthenticator do end describe '#find_sessionless_user' do - let!(:access_token_user) { build(:user) } - let!(:feed_token_user) { build(:user) } - let!(:static_object_token_user) { build(:user) } - let!(:job_token_user) { build(:user) } - let!(:lfs_token_user) { build(:user) } - let!(:basic_auth_access_token_user) { build(:user) } - let!(:basic_auth_password_user) { build(:user) } - - it 'returns access_token user first' do + let_it_be(:dependency_proxy_user) { build(:user) } + let_it_be(:access_token_user) { build(:user) } + let_it_be(:feed_token_user) { build(:user) } + let_it_be(:static_object_token_user) { build(:user) } + let_it_be(:job_token_user) { build(:user) } + let_it_be(:lfs_token_user) { build(:user) } + let_it_be(:basic_auth_access_token_user) { build(:user) } + let_it_be(:basic_auth_password_user) { build(:user) } + + it 'returns dependency_proxy user first' do + allow_any_instance_of(described_class).to receive(:find_user_from_dependency_proxy_token) + .and_return(dependency_proxy_user) + + allow_any_instance_of(described_class).to receive(:find_user_from_web_access_token).and_return(access_token_user) + + expect(subject.find_sessionless_user(:api)).to eq dependency_proxy_user + end + + it 'returns access_token user if no dependency_proxy user found' do allow_any_instance_of(described_class).to receive(:find_user_from_web_access_token) .with(anything, scopes: [:api, :read_api]) .and_return(access_token_user) @@ -154,6 +166,75 @@ RSpec.describe Gitlab::Auth::RequestAuthenticator do expect(subject.find_sessionless_user(:api)).to be_blank end + + context 'dependency proxy' do + let_it_be(:dependency_proxy_user) { create(:user) } + + let(:token) { build_jwt(dependency_proxy_user).encoded } + let(:authenticator) { described_class.new(request) } + + subject { authenticator.find_sessionless_user(:api) } + + before do + env['SCRIPT_NAME'] = accessed_path + env['HTTP_AUTHORIZATION'] = "Bearer #{token}" + end + + shared_examples 'identifying dependency proxy urls properly with' do |user_type| + context 'with pulling a manifest' do + let(:accessed_path) { '/v2/group1/dependency_proxy/containers/alpine/manifests/latest' } + + it { is_expected.to eq(dependency_proxy_user) } if user_type == :user + it { is_expected.to eq(nil) } if user_type == :no_user + end + + context 'with pulling a blob' do + let(:accessed_path) { '/v2/group1/dependency_proxy/containers/alpine/blobs/sha256:a0d0a0d46f8b52473982a3c466318f479767577551a53ffc9074c9fa7035982e' } + + it { is_expected.to eq(dependency_proxy_user) } if user_type == :user + it { is_expected.to eq(nil) } if user_type == :no_user + end + + context 'with any other path' do + let(:accessed_path) { '/foo/bar' } + + it { is_expected.to eq(nil) } + end + end + + context 'with a user' do + it_behaves_like 'identifying dependency proxy urls properly with', :user + + context 'with an invalid id' do + let(:token) { build_jwt { |jwt| jwt['user_id'] = 'this_is_not_a_user' } } + + it_behaves_like 'identifying dependency proxy urls properly with', :no_user + end + end + + context 'with a deploy token' do + let_it_be(:dependency_proxy_user) { create(:deploy_token) } + + it_behaves_like 'identifying dependency proxy urls properly with', :no_user + end + + context 'with no jwt token' do + let(:token) { nil } + + it_behaves_like 'identifying dependency proxy urls properly with', :no_user + end + + context 'with an expired jwt token' do + let(:token) { build_jwt(dependency_proxy_user).encoded } + let(:accessed_path) { '/v2/group1/dependency_proxy/containers/alpine/manifests/latest' } + + it 'returns nil' do + travel_to(Time.zone.now + Auth::DependencyProxyAuthenticationService.token_expire_at + 1.minute) do + expect(subject).to eq(nil) + end + end + end + end end describe '#find_personal_access_token_from_http_basic_auth' do @@ -201,8 +282,8 @@ RSpec.describe Gitlab::Auth::RequestAuthenticator do end describe '#find_user_from_job_token' do - let!(:user) { build(:user) } - let!(:job) { build(:ci_build, user: user, status: :running) } + let_it_be(:user) { build(:user) } + let_it_be(:job) { build(:ci_build, user: user, status: :running) } before do env[Gitlab::Auth::AuthFinders::JOB_TOKEN_HEADER] = 'token' @@ -239,7 +320,7 @@ RSpec.describe Gitlab::Auth::RequestAuthenticator do end describe '#runner' do - let!(:runner) { build(:ci_runner) } + let_it_be(:runner) { build(:ci_runner) } it 'returns the runner using #find_runner_from_token' do expect_any_instance_of(described_class) diff --git a/spec/lib/gitlab/background_migration/fix_first_mentioned_in_commit_at_spec.rb b/spec/lib/gitlab/background_migration/fix_first_mentioned_in_commit_at_spec.rb new file mode 100644 index 00000000000..d2bfa86f0d1 --- /dev/null +++ b/spec/lib/gitlab/background_migration/fix_first_mentioned_in_commit_at_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::FixFirstMentionedInCommitAt, :migration, schema: 20211004110500 do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:users) { table(:users) } + let(:merge_requests) { table(:merge_requests) } + let(:issues) { table(:issues) } + let(:issue_metrics) { table(:issue_metrics) } + let(:merge_requests_closing_issues) { table(:merge_requests_closing_issues) } + let(:diffs) { table(:merge_request_diffs) } + let(:ten_days_ago) { 10.days.ago } + let(:commits) do + table(:merge_request_diff_commits).tap do |t| + t.extend(SuppressCompositePrimaryKeyWarning) + end + end + + let(:namespace) { namespaces.create!(name: 'ns', path: 'ns') } + let(:project) { projects.create!(namespace_id: namespace.id) } + + let!(:issue1) do + issues.create!( + title: 'issue', + description: 'description', + project_id: project.id + ) + end + + let!(:issue2) do + issues.create!( + title: 'issue', + description: 'description', + project_id: project.id + ) + end + + let!(:merge_request1) do + merge_requests.create!( + source_branch: 'a', + target_branch: 'master', + target_project_id: project.id + ) + end + + let!(:merge_request2) do + merge_requests.create!( + source_branch: 'b', + target_branch: 'master', + target_project_id: project.id + ) + end + + let!(:merge_request_closing_issue1) do + merge_requests_closing_issues.create!(issue_id: issue1.id, merge_request_id: merge_request1.id) + end + + let!(:merge_request_closing_issue2) do + merge_requests_closing_issues.create!(issue_id: issue2.id, merge_request_id: merge_request2.id) + end + + let!(:diff1) { diffs.create!(merge_request_id: merge_request1.id) } + let!(:diff2) { diffs.create!(merge_request_id: merge_request1.id) } + + let!(:other_diff) { diffs.create!(merge_request_id: merge_request2.id) } + + let!(:commit1) do + commits.create!( + merge_request_diff_id: diff2.id, + relative_order: 0, + sha: Gitlab::Database::ShaAttribute.serialize('aaa'), + authored_date: 5.days.ago + ) + end + + let!(:commit2) do + commits.create!( + merge_request_diff_id: diff2.id, + relative_order: 1, + sha: Gitlab::Database::ShaAttribute.serialize('aaa'), + authored_date: 10.days.ago + ) + end + + let!(:commit3) do + commits.create!( + merge_request_diff_id: other_diff.id, + relative_order: 1, + sha: Gitlab::Database::ShaAttribute.serialize('aaa'), + authored_date: 5.days.ago + ) + end + + def run_migration + described_class + .new + .perform(issue_metrics.minimum(:issue_id), issue_metrics.maximum(:issue_id)) + end + + it "marks successful slices as completed" do + min_issue_id = issue_metrics.minimum(:issue_id) + max_issue_id = issue_metrics.maximum(:issue_id) + + expect(subject).to receive(:mark_job_as_succeeded).with(min_issue_id, max_issue_id) + + subject.perform(min_issue_id, max_issue_id) + end + + context 'when the persisted first_mentioned_in_commit_at is later than the first commit authored_date' do + it 'updates the issue_metrics record' do + record1 = issue_metrics.create!(issue_id: issue1.id, first_mentioned_in_commit_at: Time.current) + record2 = issue_metrics.create!(issue_id: issue2.id, first_mentioned_in_commit_at: Time.current) + + run_migration + record1.reload + record2.reload + + expect(record1.first_mentioned_in_commit_at).to be_within(2.seconds).of(commit2.authored_date) + expect(record2.first_mentioned_in_commit_at).to be_within(2.seconds).of(commit3.authored_date) + end + end + + context 'when the persisted first_mentioned_in_commit_at is earlier than the first commit authored_date' do + it 'does not update the issue_metrics record' do + record = issue_metrics.create!(issue_id: issue1.id, first_mentioned_in_commit_at: 20.days.ago) + + expect { run_migration }.not_to change { record.reload.first_mentioned_in_commit_at } + end + end + + context 'when the first_mentioned_in_commit_at is null' do + it 'does nothing' do + record = issue_metrics.create!(issue_id: issue1.id, first_mentioned_in_commit_at: nil) + + expect { run_migration }.not_to change { record.reload.first_mentioned_in_commit_at } + end + end +end diff --git a/spec/lib/gitlab/background_migration/fix_promoted_epics_discussion_ids_spec.rb b/spec/lib/gitlab/background_migration/fix_promoted_epics_discussion_ids_spec.rb index 452fc962c7b..35ec8be691a 100644 --- a/spec/lib/gitlab/background_migration/fix_promoted_epics_discussion_ids_spec.rb +++ b/spec/lib/gitlab/background_migration/fix_promoted_epics_discussion_ids_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::FixPromotedEpicsDiscussionIds, schema: 20190715193142 do +RSpec.describe Gitlab::BackgroundMigration::FixPromotedEpicsDiscussionIds, schema: 20181228175414 do let(:namespaces) { table(:namespaces) } let(:users) { table(:users) } let(:epics) { table(:epics) } diff --git a/spec/lib/gitlab/background_migration/fix_user_namespace_names_spec.rb b/spec/lib/gitlab/background_migration/fix_user_namespace_names_spec.rb index 0d0ad2cc39e..95509f9b897 100644 --- a/spec/lib/gitlab/background_migration/fix_user_namespace_names_spec.rb +++ b/spec/lib/gitlab/background_migration/fix_user_namespace_names_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::FixUserNamespaceNames, schema: 20190620112608 do +RSpec.describe Gitlab::BackgroundMigration::FixUserNamespaceNames, schema: 20181228175414 do let(:namespaces) { table(:namespaces) } let(:users) { table(:users) } let(:user) { users.create!(name: "The user's full name", projects_limit: 10, username: 'not-null', email: '1') } diff --git a/spec/lib/gitlab/background_migration/fix_user_project_route_names_spec.rb b/spec/lib/gitlab/background_migration/fix_user_project_route_names_spec.rb index 211693d917b..b4444df674e 100644 --- a/spec/lib/gitlab/background_migration/fix_user_project_route_names_spec.rb +++ b/spec/lib/gitlab/background_migration/fix_user_project_route_names_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::FixUserProjectRouteNames, schema: 20190620112608 do +RSpec.describe Gitlab::BackgroundMigration::FixUserProjectRouteNames, schema: 20181228175414 do let(:namespaces) { table(:namespaces) } let(:users) { table(:users) } let(:routes) { table(:routes) } diff --git a/spec/lib/gitlab/background_migration/migrate_null_private_profile_to_false_spec.rb b/spec/lib/gitlab/background_migration/migrate_null_private_profile_to_false_spec.rb deleted file mode 100644 index 6ff1157cb86..00000000000 --- a/spec/lib/gitlab/background_migration/migrate_null_private_profile_to_false_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::BackgroundMigration::MigrateNullPrivateProfileToFalse, schema: 20190620105427 do - let(:users) { table(:users) } - - it 'correctly migrates nil private_profile to false' do - private_profile_true = users.create!(private_profile: true, projects_limit: 1, email: 'a@b.com') - private_profile_false = users.create!(private_profile: false, projects_limit: 1, email: 'b@c.com') - private_profile_nil = users.create!(private_profile: nil, projects_limit: 1, email: 'c@d.com') - - described_class.new.perform(private_profile_true.id, private_profile_nil.id) - - private_profile_true.reload - private_profile_false.reload - private_profile_nil.reload - - expect(private_profile_true.private_profile).to eq(true) - expect(private_profile_false.private_profile).to eq(false) - expect(private_profile_nil.private_profile).to eq(false) - end -end diff --git a/spec/lib/gitlab/background_migration/migrate_pages_metadata_spec.rb b/spec/lib/gitlab/background_migration/migrate_pages_metadata_spec.rb index 815dc2e73e5..b6d93b9ff54 100644 --- a/spec/lib/gitlab/background_migration/migrate_pages_metadata_spec.rb +++ b/spec/lib/gitlab/background_migration/migrate_pages_metadata_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::MigratePagesMetadata, schema: 20190919040324 do +RSpec.describe Gitlab::BackgroundMigration::MigratePagesMetadata, schema: 20181228175414 do let(:projects) { table(:projects) } subject(:migrate_pages_metadata) { described_class.new } diff --git a/spec/lib/gitlab/background_migration/populate_merge_request_assignees_table_spec.rb b/spec/lib/gitlab/background_migration/populate_merge_request_assignees_table_spec.rb index 4e7872a9a1b..1d8eed53553 100644 --- a/spec/lib/gitlab/background_migration/populate_merge_request_assignees_table_spec.rb +++ b/spec/lib/gitlab/background_migration/populate_merge_request_assignees_table_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::PopulateMergeRequestAssigneesTable, schema: 20190315191339 do +RSpec.describe Gitlab::BackgroundMigration::PopulateMergeRequestAssigneesTable, schema: 20181228175414 do let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } let(:users) { table(:users) } diff --git a/spec/lib/gitlab/background_migration/populate_topics_total_projects_count_cache_spec.rb b/spec/lib/gitlab/background_migration/populate_topics_total_projects_count_cache_spec.rb new file mode 100644 index 00000000000..8e07b43f5b9 --- /dev/null +++ b/spec/lib/gitlab/background_migration/populate_topics_total_projects_count_cache_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::PopulateTopicsTotalProjectsCountCache, schema: 20211006060436 do + it 'correctly populates total projects count cache' do + namespaces = table(:namespaces) + projects = table(:projects) + topics = table(:topics) + project_topics = table(:project_topics) + + group = namespaces.create!(name: 'group', path: 'group') + project_1 = projects.create!(namespace_id: group.id) + project_2 = projects.create!(namespace_id: group.id) + project_3 = projects.create!(namespace_id: group.id) + topic_1 = topics.create!(name: 'Topic1') + topic_2 = topics.create!(name: 'Topic2') + topic_3 = topics.create!(name: 'Topic3') + topic_4 = topics.create!(name: 'Topic4') + + project_topics.create!(project_id: project_1.id, topic_id: topic_1.id) + project_topics.create!(project_id: project_1.id, topic_id: topic_3.id) + project_topics.create!(project_id: project_2.id, topic_id: topic_3.id) + project_topics.create!(project_id: project_1.id, topic_id: topic_4.id) + project_topics.create!(project_id: project_2.id, topic_id: topic_4.id) + project_topics.create!(project_id: project_3.id, topic_id: topic_4.id) + + subject.perform(topic_1.id, topic_4.id) + + expect(topic_1.reload.total_projects_count).to eq(1) + expect(topic_2.reload.total_projects_count).to eq(0) + expect(topic_3.reload.total_projects_count).to eq(2) + expect(topic_4.reload.total_projects_count).to eq(3) + end +end diff --git a/spec/lib/gitlab/backtrace_cleaner_spec.rb b/spec/lib/gitlab/backtrace_cleaner_spec.rb index 51d99bf5f74..e46a90e8606 100644 --- a/spec/lib/gitlab/backtrace_cleaner_spec.rb +++ b/spec/lib/gitlab/backtrace_cleaner_spec.rb @@ -22,9 +22,6 @@ RSpec.describe Gitlab::BacktraceCleaner do "lib/gitlab/git/repository.rb:1451:in `gitaly_migrate'", "lib/gitlab/git/commit.rb:66:in `find'", "app/models/repository.rb:1047:in `find_commit'", - "lib/gitlab/metrics/instrumentation.rb:159:in `block in find_commit'", - "lib/gitlab/metrics/method_call.rb:36:in `measure'", - "lib/gitlab/metrics/instrumentation.rb:159:in `find_commit'", "app/models/repository.rb:113:in `commit'", "lib/gitlab/i18n.rb:50:in `with_locale'", "lib/gitlab/middleware/multipart.rb:95:in `call'", diff --git a/spec/lib/gitlab/cache/import/caching_spec.rb b/spec/lib/gitlab/cache/import/caching_spec.rb index f770960e27a..946a7c604a1 100644 --- a/spec/lib/gitlab/cache/import/caching_spec.rb +++ b/spec/lib/gitlab/cache/import/caching_spec.rb @@ -58,6 +58,16 @@ RSpec.describe Gitlab::Cache::Import::Caching, :clean_gitlab_redis_cache do end end + describe '.increment' do + it 'increment a key and returns the current value' do + expect(described_class.increment('foo')).to eq(1) + + value = Gitlab::Redis::Cache.with { |r| r.get(described_class.cache_key_for('foo')) } + + expect(value.to_i).to eq(1) + end + end + describe '.set_add' do it 'adds a value to a set' do described_class.set_add('foo', 10) diff --git a/spec/lib/gitlab/chat/command_spec.rb b/spec/lib/gitlab/chat/command_spec.rb index d99c07d1fa3..c8b4b3f73b2 100644 --- a/spec/lib/gitlab/chat/command_spec.rb +++ b/spec/lib/gitlab/chat/command_spec.rb @@ -72,6 +72,7 @@ RSpec.describe Gitlab::Chat::Command do expect(vars['CHAT_INPUT']).to eq('foo') expect(vars['CHAT_CHANNEL']).to eq('123') + expect(vars['CHAT_USER_ID']).to eq(chat_name.chat_id) end end end diff --git a/spec/lib/gitlab/checks/matching_merge_request_spec.rb b/spec/lib/gitlab/checks/matching_merge_request_spec.rb index 2e562a5a350..c65a1e4d656 100644 --- a/spec/lib/gitlab/checks/matching_merge_request_spec.rb +++ b/spec/lib/gitlab/checks/matching_merge_request_spec.rb @@ -31,33 +31,30 @@ RSpec.describe Gitlab::Checks::MatchingMergeRequest do expect(matcher.match?).to be false end - context 'with load balancing disabled', :request_store, :redis do - before do - expect(::Gitlab::Database::LoadBalancing).to receive(:enable?).at_least(:once).and_return(false) - expect(::Gitlab::Database::LoadBalancing::Sticking).not_to receive(:unstick_or_continue_sticking) - expect(::Gitlab::Database::LoadBalancing::Sticking).not_to receive(:select_valid_replicas) - end - - it 'does not attempt to stick to primary' do - expect(subject.match?).to be true - end - - it 'increments no counters' do - expect { subject.match? } - .to change { total_counter.get }.by(0) - .and change { stale_counter.get }.by(0) - end - end - - context 'with load balancing enabled', :db_load_balancing do + context 'with load balancing enabled' do let(:session) { ::Gitlab::Database::LoadBalancing::Session.current } let(:all_caught_up) { true } before do - allow(::Gitlab::Database::LoadBalancing::Sticking).to receive(:all_caught_up?).and_return(all_caught_up) + Gitlab::Database::LoadBalancing::Session.clear_session + + allow(::ApplicationRecord.sticking) + .to receive(:all_caught_up?) + .and_return(all_caught_up) + + expect(::ApplicationRecord.sticking) + .to receive(:select_valid_host) + .with(:project, project.id) + .and_call_original + + allow(::ApplicationRecord.sticking) + .to receive(:select_caught_up_replicas) + .with(:project, project.id) + .and_return(all_caught_up) + end - expect(::Gitlab::Database::LoadBalancing::Sticking).to receive(:select_valid_host).with(:project, project.id).and_call_original - allow(::Gitlab::Database::LoadBalancing::Sticking).to receive(:select_caught_up_replicas).with(:project, project.id).and_return(all_caught_up) + after do + Gitlab::Database::LoadBalancing::Session.clear_session end shared_examples 'secondary that has caught up to a primary' do diff --git a/spec/lib/gitlab/ci/build/auto_retry_spec.rb b/spec/lib/gitlab/ci/build/auto_retry_spec.rb index e83e1326206..fc5999d59ac 100644 --- a/spec/lib/gitlab/ci/build/auto_retry_spec.rb +++ b/spec/lib/gitlab/ci/build/auto_retry_spec.rb @@ -24,6 +24,7 @@ RSpec.describe Gitlab::Ci::Build::AutoRetry do "default for scheduler failure" | 1 | {} | :scheduler_failure | true "quota is exceeded" | 0 | { max: 2 } | :ci_quota_exceeded | false "no matching runner" | 0 | { max: 2 } | :no_matching_runner | false + "missing dependencies" | 0 | { max: 2 } | :missing_dependency_failure | false end with_them do diff --git a/spec/lib/gitlab/ci/config/entry/retry_spec.rb b/spec/lib/gitlab/ci/config/entry/retry_spec.rb index b38387a437e..84ef5344a8b 100644 --- a/spec/lib/gitlab/ci/config/entry/retry_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/retry_spec.rb @@ -101,7 +101,6 @@ RSpec.describe Gitlab::Ci::Config::Entry::Retry do api_failure stuck_or_timeout_failure runner_system_failure - missing_dependency_failure runner_unsupported stale_schedule job_execution_timeout diff --git a/spec/lib/gitlab/ci/config/external/mapper_spec.rb b/spec/lib/gitlab/ci/config/external/mapper_spec.rb index a471997e43a..cebe8984741 100644 --- a/spec/lib/gitlab/ci/config/external/mapper_spec.rb +++ b/spec/lib/gitlab/ci/config/external/mapper_spec.rb @@ -363,17 +363,6 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do expect(subject).to contain_exactly(an_instance_of(Gitlab::Ci::Config::External::File::Remote), an_instance_of(Gitlab::Ci::Config::External::File::Local)) end - - context 'when the FF ci_include_rules is disabled' do - before do - stub_feature_flags(ci_include_rules: false) - end - - it 'includes the file' do - expect(subject).to contain_exactly(an_instance_of(Gitlab::Ci::Config::External::File::Remote), - an_instance_of(Gitlab::Ci::Config::External::File::Local)) - end - end end context 'when the rules does not match' do @@ -382,17 +371,6 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do it 'does not include the file' do expect(subject).to contain_exactly(an_instance_of(Gitlab::Ci::Config::External::File::Remote)) end - - context 'when the FF ci_include_rules is disabled' do - before do - stub_feature_flags(ci_include_rules: false) - end - - it 'includes the file' do - expect(subject).to contain_exactly(an_instance_of(Gitlab::Ci::Config::External::File::Remote), - an_instance_of(Gitlab::Ci::Config::External::File::Local)) - end - end end end end diff --git a/spec/lib/gitlab/ci/config/external/processor_spec.rb b/spec/lib/gitlab/ci/config/external/processor_spec.rb index e032d372ecb..c2f28253f54 100644 --- a/spec/lib/gitlab/ci/config/external/processor_spec.rb +++ b/spec/lib/gitlab/ci/config/external/processor_spec.rb @@ -402,5 +402,17 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do expect(output.keys).to match_array([:image, :my_build, :my_test]) end end + + context 'when rules defined' do + context 'when a rule is invalid' do + let(:values) do + { include: [{ local: 'builds.yml', rules: [{ exists: ['$MY_VAR'] }] }] } + end + + it 'raises IncludeError' do + expect { subject }.to raise_error(described_class::IncludeError, /invalid include rule/) + end + end + end end end diff --git a/spec/lib/gitlab/ci/config/external/rules_spec.rb b/spec/lib/gitlab/ci/config/external/rules_spec.rb index 89ea13d710d..9a5c29befa2 100644 --- a/spec/lib/gitlab/ci/config/external/rules_spec.rb +++ b/spec/lib/gitlab/ci/config/external/rules_spec.rb @@ -16,7 +16,7 @@ RSpec.describe Gitlab::Ci::Config::External::Rules do it { is_expected.to eq(true) } end - context 'when there is a rule' do + context 'when there is a rule with if' do let(:rule_hashes) { [{ if: '$MY_VAR == "hello"' }] } context 'when the rule matches' do @@ -31,5 +31,23 @@ RSpec.describe Gitlab::Ci::Config::External::Rules do it { is_expected.to eq(false) } end end + + context 'when there is a rule with if and when' do + let(:rule_hashes) { [{ if: '$MY_VAR == "hello"', when: 'on_success' }] } + + it 'raises an error' do + expect { result }.to raise_error(described_class::InvalidIncludeRulesError, + 'invalid include rule: {:if=>"$MY_VAR == \"hello\"", :when=>"on_success"}') + end + end + + context 'when there is a rule with exists' do + let(:rule_hashes) { [{ exists: ['$MY_VAR'] }] } + + it 'raises an error' do + expect { result }.to raise_error(described_class::InvalidIncludeRulesError, + 'invalid include rule: {:exists=>["$MY_VAR"]}') + end + 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 0c28515b574..3aa6b2e3c05 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb @@ -440,17 +440,30 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do context 'when the environment name is invalid' do let(:attributes) { { name: 'deploy', ref: 'master', environment: '!!!' } } - it_behaves_like 'non-deployment job' - it_behaves_like 'ensures environment inexistence' + it 'fails the job with a failure reason and does not create an environment' do + expect(subject).to be_failed + expect(subject).to be_environment_creation_failure + expect(subject.metadata.expanded_environment_name).to be_nil + expect(Environment.exists?(name: expected_environment_name)).to eq(false) + end + + context 'when surface_environment_creation_failure feature flag is disabled' do + before do + stub_feature_flags(surface_environment_creation_failure: false) + end - it 'tracks an exception' do - expect(Gitlab::ErrorTracking).to receive(:track_exception) - .with(an_instance_of(described_class::EnvironmentCreationFailure), - project_id: project.id, - reason: %q{Name can contain only letters, digits, '-', '_', '/', '$', '{', '}', '.', and spaces, but it cannot start or end with '/'}) - .once + it_behaves_like 'non-deployment job' + it_behaves_like 'ensures environment inexistence' - subject + it 'tracks an exception' do + expect(Gitlab::ErrorTracking).to receive(:track_exception) + .with(an_instance_of(described_class::EnvironmentCreationFailure), + project_id: project.id, + reason: %q{Name can contain only letters, digits, '-', '_', '/', '$', '{', '}', '.', and spaces, but it cannot start or end with '/'}) + .once + + subject + end end end end diff --git a/spec/lib/gitlab/ci/reports/security/flag_spec.rb b/spec/lib/gitlab/ci/reports/security/flag_spec.rb index 27f83694ac2..d677425a8da 100644 --- a/spec/lib/gitlab/ci/reports/security/flag_spec.rb +++ b/spec/lib/gitlab/ci/reports/security/flag_spec.rb @@ -18,9 +18,9 @@ RSpec.describe Gitlab::Ci::Reports::Security::Flag do end end - describe '#to_hash' do + describe '#to_h' do it 'returns expected hash' do - expect(security_flag.to_hash).to eq( + expect(security_flag.to_h).to eq( { flag_type: :false_positive, origin: 'post analyzer X', diff --git a/spec/lib/gitlab/ci/templates/templates_spec.rb b/spec/lib/gitlab/ci/templates/templates_spec.rb index 81fc66c4a11..cdda7e953d0 100644 --- a/spec/lib/gitlab/ci/templates/templates_spec.rb +++ b/spec/lib/gitlab/ci/templates/templates_spec.rb @@ -13,13 +13,6 @@ RSpec.describe 'CI YML Templates' do excluded + ["Terraform.gitlab-ci.yml"] end - before do - stub_feature_flags( - redirect_to_latest_template_terraform: false, - redirect_to_latest_template_security_api_fuzzing: false, - redirect_to_latest_template_security_dast: false) - end - shared_examples 'require default stages to be included' do it 'require default stages to be included' do expect(subject.stages).to include(*Gitlab::Ci::Config::Entry::Stages.default) diff --git a/spec/lib/gitlab/ci/trace/archive_spec.rb b/spec/lib/gitlab/ci/trace/archive_spec.rb new file mode 100644 index 00000000000..c9fc4e720c4 --- /dev/null +++ b/spec/lib/gitlab/ci/trace/archive_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Trace::Archive do + let_it_be(:job) { create(:ci_build, :success, :trace_live) } + let_it_be_with_reload(:trace_metadata) { create(:ci_build_trace_metadata, build: job) } + let_it_be(:src_checksum) do + job.trace.read { |stream| Digest::MD5.hexdigest(stream.raw) } + end + + let(:metrics) { spy('metrics') } + + describe '#execute' do + subject { described_class.new(job, trace_metadata, metrics) } + + it 'computes and assigns checksum' do + Gitlab::Ci::Trace::ChunkedIO.new(job) do |stream| + expect { subject.execute!(stream) }.to change { Ci::JobArtifact.count }.by(1) + end + + expect(trace_metadata.checksum).to eq(src_checksum) + expect(trace_metadata.trace_artifact).to eq(job.job_artifacts_trace) + end + + context 'validating artifact checksum' do + let(:trace) { 'abc' } + let(:stream) { StringIO.new(trace, 'rb') } + let(:src_checksum) { Digest::MD5.hexdigest(trace) } + + context 'when the object store is disabled' do + before do + stub_artifacts_object_storage(enabled: false) + end + + it 'skips validation' do + subject.execute!(stream) + + expect(trace_metadata.checksum).to eq(src_checksum) + expect(trace_metadata.remote_checksum).to be_nil + expect(metrics) + .not_to have_received(:increment_error_counter) + .with(type: :archive_invalid_checksum) + end + end + + context 'with background_upload enabled' do + before do + stub_artifacts_object_storage(background_upload: true) + end + + it 'skips validation' do + subject.execute!(stream) + + expect(trace_metadata.checksum).to eq(src_checksum) + expect(trace_metadata.remote_checksum).to be_nil + expect(metrics) + .not_to have_received(:increment_error_counter) + .with(type: :archive_invalid_checksum) + end + end + + context 'with direct_upload enabled' do + before do + stub_artifacts_object_storage(direct_upload: true) + end + + it 'validates the archived trace' do + subject.execute!(stream) + + expect(trace_metadata.checksum).to eq(src_checksum) + expect(trace_metadata.remote_checksum).to eq(src_checksum) + expect(metrics) + .not_to have_received(:increment_error_counter) + .with(type: :archive_invalid_checksum) + end + + context 'when the checksum does not match' do + let(:invalid_remote_checksum) { SecureRandom.hex } + + before do + expect(::Gitlab::Ci::Trace::RemoteChecksum) + .to receive(:new) + .with(an_instance_of(Ci::JobArtifact)) + .and_return(double(md5_checksum: invalid_remote_checksum)) + end + + it 'validates the archived trace' do + subject.execute!(stream) + + expect(trace_metadata.checksum).to eq(src_checksum) + expect(trace_metadata.remote_checksum).to eq(invalid_remote_checksum) + expect(metrics) + .to have_received(:increment_error_counter) + .with(type: :archive_invalid_checksum) + end + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/trace/metrics_spec.rb b/spec/lib/gitlab/ci/trace/metrics_spec.rb index 6518d0ab075..53e55a57973 100644 --- a/spec/lib/gitlab/ci/trace/metrics_spec.rb +++ b/spec/lib/gitlab/ci/trace/metrics_spec.rb @@ -15,4 +15,27 @@ RSpec.describe Gitlab::Ci::Trace::Metrics, :prometheus do end end end + + describe '#increment_error_counter' do + context 'when the operation type is known' do + it 'increments the counter' do + subject.increment_error_counter(type: :chunks_invalid_size) + subject.increment_error_counter(type: :chunks_invalid_checksum) + subject.increment_error_counter(type: :archive_invalid_checksum) + + expect(described_class.trace_errors_counter.get(type: :chunks_invalid_size)).to eq 1 + expect(described_class.trace_errors_counter.get(type: :chunks_invalid_checksum)).to eq 1 + expect(described_class.trace_errors_counter.get(type: :archive_invalid_checksum)).to eq 1 + + expect(described_class.trace_errors_counter.values.count).to eq 3 + end + end + + context 'when the operation type is known' do + it 'raises an exception' do + expect { subject.increment_error_counter(type: :invalid_type) } + .to raise_error(ArgumentError) + end + end + end end diff --git a/spec/lib/gitlab/ci/trace/remote_checksum_spec.rb b/spec/lib/gitlab/ci/trace/remote_checksum_spec.rb new file mode 100644 index 00000000000..8837ebc3652 --- /dev/null +++ b/spec/lib/gitlab/ci/trace/remote_checksum_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Trace::RemoteChecksum do + let_it_be(:job) { create(:ci_build, :success) } + + let(:file_store) { JobArtifactUploader::Store::LOCAL } + let(:trace_artifact) { create(:ci_job_artifact, :trace, job: job, file_store: file_store) } + let(:checksum) { Digest::MD5.hexdigest(trace_artifact.file.read) } + let(:base64checksum) { Digest::MD5.base64digest(trace_artifact.file.read) } + let(:fetcher) { described_class.new(trace_artifact) } + + describe '#md5_checksum' do + subject { fetcher.md5_checksum } + + context 'when the file is stored locally' do + it { is_expected.to be_nil } + end + + context 'when object store is enabled' do + before do + stub_artifacts_object_storage + end + + context 'with local files' do + it { is_expected.to be_nil } + end + + context 'with remote files' do + let(:file_store) { JobArtifactUploader::Store::REMOTE } + + context 'when the feature flag is disabled' do + before do + stub_feature_flags(ci_archived_build_trace_checksum: false) + end + + it { is_expected.to be_nil } + end + + context 'with AWS as provider' do + it { is_expected.to eq(checksum) } + end + + context 'with Google as provider' do + before do + spy_file = spy(:file) + expect(fetcher).to receive(:provider_google?) { true } + expect(fetcher).not_to receive(:provider_aws?) { false } + allow(spy_file).to receive(:attributes).and_return(metadata) + + allow_next_found_instance_of(Ci::JobArtifact) do |trace_artifact| + allow(trace_artifact.file).to receive(:file) { spy_file } + end + end + + context 'when the response does not include :content_md5' do + let(:metadata) {{}} + + it 'raises an exception' do + expect { subject }.to raise_error KeyError, /content_md5/ + end + end + + context 'when the response include :content_md5' do + let(:metadata) {{ content_md5: base64checksum }} + + it { is_expected.to eq(checksum) } + end + end + + context 'with unsupported providers' do + let(:file_store) { JobArtifactUploader::Store::REMOTE } + + before do + expect(fetcher).to receive(:provider_aws?) { false } + expect(fetcher).to receive(:provider_google?) { false } + end + + it { is_expected.to be_nil } + end + end + end + end +end diff --git a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb index 239eff11bf3..3ec332dace5 100644 --- a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb +++ b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb @@ -11,6 +11,7 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do directives: { base_uri: 'http://example.com', child_src: "'self' https://child.example.com", + connect_src: "'self' ws://example.com", default_src: "'self' https://other.example.com", script_src: "'self' https://script.exammple.com ", worker_src: "data: https://worker.example.com", @@ -52,6 +53,28 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do expect(directives['child_src']).to eq(directives['frame_src']) end + context 'adds all websocket origins to support Safari' do + it 'with insecure domain' do + stub_config_setting(host: 'example.com', https: false) + expect(directives['connect_src']).to eq("'self' ws://example.com") + end + + it 'with secure domain' do + stub_config_setting(host: 'example.com', https: true) + expect(directives['connect_src']).to eq("'self' wss://example.com") + end + + it 'with custom port' do + stub_config_setting(host: 'example.com', port: '1234') + expect(directives['connect_src']).to eq("'self' ws://example.com:1234") + end + + it 'with custom port and secure domain' do + stub_config_setting(host: 'example.com', https: true, port: '1234') + expect(directives['connect_src']).to eq("'self' wss://example.com:1234") + end + end + context 'when CDN host is defined' do before do stub_config_setting(cdn_host: 'https://example.com') @@ -67,10 +90,11 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do context 'when sentry is configured' do before do stub_sentry_settings + stub_config_setting(host: 'example.com') end it 'adds sentry path to CSP without user' do - expect(directives['connect_src']).to eq("'self' dummy://example.com/43") + expect(directives['connect_src']).to eq("'self' ws://example.com dummy://example.com/43") end end @@ -113,6 +137,7 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do expect(policy.directives['base-uri']).to eq([csp_config[:directives][:base_uri]]) expect(policy.directives['default-src']).to eq(expected_config(:default_src)) + expect(policy.directives['connect-src']).to eq(expected_config(:connect_src)) expect(policy.directives['child-src']).to eq(expected_config(:child_src)) expect(policy.directives['worker-src']).to eq(expected_config(:worker_src)) expect(policy.directives['report-uri']).to eq(expected_config(:report_uri)) diff --git a/spec/lib/gitlab/database/bulk_update_spec.rb b/spec/lib/gitlab/database/bulk_update_spec.rb index dbafada26ca..9a6463c99fa 100644 --- a/spec/lib/gitlab/database/bulk_update_spec.rb +++ b/spec/lib/gitlab/database/bulk_update_spec.rb @@ -91,45 +91,38 @@ RSpec.describe Gitlab::Database::BulkUpdate do .to eq(['MR a', 'Issue a', 'Issue b']) end - shared_examples 'basic functionality' do - it 'sets multiple values' do - create_default(:user) - create_default(:project) - - i_a, i_b = create_list(:issue, 2) + context 'validates prepared_statements support', :reestablished_active_record_base do + using RSpec::Parameterized::TableSyntax - mapping = { - i_a => { title: 'Issue a' }, - i_b => { title: 'Issue b' } - } + where(:prepared_statements) do + [false, true] + end - described_class.execute(%i[title], mapping) + before do + configuration_hash = ActiveRecord::Base.connection_db_config.configuration_hash - expect([i_a, i_b].map { |x| x.reset.title }) - .to eq(['Issue a', 'Issue b']) + ActiveRecord::Base.establish_connection( + configuration_hash.merge(prepared_statements: prepared_statements) + ) end - end - include_examples 'basic functionality' + with_them do + it 'sets multiple values' do + create_default(:user) + create_default(:project) - context 'when prepared statements are configured differently to the normal test environment' do - before do - klass = Class.new(ActiveRecord::Base) do - def self.abstract_class? - true # So it gets its own connection - end - end + i_a, i_b = create_list(:issue, 2) - stub_const('ActiveRecordBasePreparedStatementsInverted', klass) + mapping = { + i_a => { title: 'Issue a' }, + i_b => { title: 'Issue b' } + } - c = ActiveRecord::Base.connection.instance_variable_get(:@config) - inverted = c.merge(prepared_statements: !ActiveRecord::Base.connection.prepared_statements) - ActiveRecordBasePreparedStatementsInverted.establish_connection(inverted) + described_class.execute(%i[title], mapping) - allow(ActiveRecord::Base).to receive(:connection_specification_name) - .and_return(ActiveRecordBasePreparedStatementsInverted.connection_specification_name) + expect([i_a, i_b].map { |x| x.reset.title }) + .to eq(['Issue a', 'Issue b']) + end end - - include_examples 'basic functionality' end end diff --git a/spec/lib/gitlab/database/connection_spec.rb b/spec/lib/gitlab/database/connection_spec.rb index 7f94d7af4a9..ee1df141cd6 100644 --- a/spec/lib/gitlab/database/connection_spec.rb +++ b/spec/lib/gitlab/database/connection_spec.rb @@ -126,15 +126,7 @@ RSpec.describe Gitlab::Database::Connection do end end - describe '#disable_prepared_statements' do - around do |example| - original_config = connection.scope.connection.pool.db_config - - example.run - - connection.scope.establish_connection(original_config) - end - + describe '#disable_prepared_statements', :reestablished_active_record_base do it 'disables prepared statements' do connection.scope.establish_connection( ::Gitlab::Database.main.config.merge(prepared_statements: true) diff --git a/spec/lib/gitlab/database/consistency_spec.rb b/spec/lib/gitlab/database/consistency_spec.rb index 35fa65512ae..5055be81c88 100644 --- a/spec/lib/gitlab/database/consistency_spec.rb +++ b/spec/lib/gitlab/database/consistency_spec.rb @@ -7,6 +7,14 @@ RSpec.describe Gitlab::Database::Consistency do Gitlab::Database::LoadBalancing::Session.current end + before do + Gitlab::Database::LoadBalancing::Session.clear_session + end + + after do + Gitlab::Database::LoadBalancing::Session.clear_session + end + describe '.with_read_consistency' do it 'sticks to primary database' do expect(session).not_to be_using_primary diff --git a/spec/lib/gitlab/database/count_spec.rb b/spec/lib/gitlab/database/count_spec.rb index d65413c2a00..e712ad09927 100644 --- a/spec/lib/gitlab/database/count_spec.rb +++ b/spec/lib/gitlab/database/count_spec.rb @@ -46,5 +46,49 @@ RSpec.describe Gitlab::Database::Count do subject end end + + context 'default strategies' do + subject { described_class.approximate_counts(models) } + + context 'with a read-only database' do + before do + allow(Gitlab::Database).to receive(:read_only?).and_return(true) + end + + it 'only uses the ExactCountStrategy' do + allow_next_instance_of(Gitlab::Database::Count::TablesampleCountStrategy) do |instance| + expect(instance).not_to receive(:count) + end + allow_next_instance_of(Gitlab::Database::Count::ReltuplesCountStrategy) do |instance| + expect(instance).not_to receive(:count) + end + expect_next_instance_of(Gitlab::Database::Count::ExactCountStrategy) do |instance| + expect(instance).to receive(:count).and_return({}) + end + + subject + end + end + + context 'with a read-write database' do + before do + allow(Gitlab::Database).to receive(:read_only?).and_return(false) + end + + it 'uses the available strategies' do + [ + Gitlab::Database::Count::TablesampleCountStrategy, + Gitlab::Database::Count::ReltuplesCountStrategy, + Gitlab::Database::Count::ExactCountStrategy + ].each do |strategy_klass| + expect_next_instance_of(strategy_klass) do |instance| + expect(instance).to receive(:count).and_return({}) + end + end + + subject + end + end + end end end diff --git a/spec/lib/gitlab/database/load_balancing/action_cable_callbacks_spec.rb b/spec/lib/gitlab/database/load_balancing/action_cable_callbacks_spec.rb index ebbbafb855f..768855464c1 100644 --- a/spec/lib/gitlab/database/load_balancing/action_cable_callbacks_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/action_cable_callbacks_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Gitlab::Database::LoadBalancing::ActionCableCallbacks, :request_store do describe '.wrapper' do it 'uses primary and then releases the connection and clears the session' do - expect(Gitlab::Database::LoadBalancing).to receive_message_chain(:proxy, :load_balancer, :release_host) + expect(Gitlab::Database::LoadBalancing).to receive(:release_hosts) expect(Gitlab::Database::LoadBalancing::Session).to receive(:clear_session) described_class.wrapper.call( @@ -18,7 +18,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::ActionCableCallbacks, :request_s context 'with an exception' do it 'releases the connection and clears the session' do - expect(Gitlab::Database::LoadBalancing).to receive_message_chain(:proxy, :load_balancer, :release_host) + expect(Gitlab::Database::LoadBalancing).to receive(:release_hosts) expect(Gitlab::Database::LoadBalancing::Session).to receive(:clear_session) expect do diff --git a/spec/lib/gitlab/database/load_balancing/active_record_proxy_spec.rb b/spec/lib/gitlab/database/load_balancing/active_record_proxy_spec.rb deleted file mode 100644 index 8886ce9756d..00000000000 --- a/spec/lib/gitlab/database/load_balancing/active_record_proxy_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Database::LoadBalancing::ActiveRecordProxy do - describe '#connection' do - it 'returns a connection proxy' do - dummy = Class.new do - include Gitlab::Database::LoadBalancing::ActiveRecordProxy - end - - proxy = double(:proxy) - - expect(Gitlab::Database::LoadBalancing).to receive(:proxy) - .and_return(proxy) - - expect(dummy.new.connection).to eq(proxy) - end - end -end diff --git a/spec/lib/gitlab/database/load_balancing/configuration_spec.rb b/spec/lib/gitlab/database/load_balancing/configuration_spec.rb index 6621e6276a5..3e5249a3dea 100644 --- a/spec/lib/gitlab/database/load_balancing/configuration_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/configuration_spec.rb @@ -108,6 +108,14 @@ RSpec.describe Gitlab::Database::LoadBalancing::Configuration do end describe '#load_balancing_enabled?' do + it 'returns false when running inside a Rake task' do + config = described_class.new(ActiveRecord::Base, %w[foo bar]) + + allow(Gitlab::Runtime).to receive(:rake?).and_return(true) + + expect(config.load_balancing_enabled?).to eq(false) + end + it 'returns true when hosts are configured' do config = described_class.new(ActiveRecord::Base, %w[foo bar]) diff --git a/spec/lib/gitlab/database/load_balancing/host_spec.rb b/spec/lib/gitlab/database/load_balancing/host_spec.rb index e2011692228..b040c7a76bd 100644 --- a/spec/lib/gitlab/database/load_balancing/host_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/host_spec.rb @@ -172,6 +172,14 @@ RSpec.describe Gitlab::Database::LoadBalancing::Host do expect(host).not_to be_online end + + it 'returns false when ActiveRecord::ConnectionNotEstablished is raised' do + allow(host) + .to receive(:check_replica_status?) + .and_raise(ActiveRecord::ConnectionNotEstablished) + + expect(host).not_to be_online + end end end diff --git a/spec/lib/gitlab/database/load_balancing/load_balancer_spec.rb b/spec/lib/gitlab/database/load_balancing/load_balancer_spec.rb index 86fae14b961..f3ce5563e38 100644 --- a/spec/lib/gitlab/database/load_balancing/load_balancer_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/load_balancer_spec.rb @@ -47,16 +47,27 @@ RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do end describe '#initialize' do - it 'ignores the hosts when the primary_only option is enabled' do + it 'ignores the hosts when load balancing is disabled' do config = Gitlab::Database::LoadBalancing::Configuration .new(ActiveRecord::Base, [db_host]) - lb = described_class.new(config, primary_only: true) + + allow(config).to receive(:load_balancing_enabled?).and_return(false) + + lb = described_class.new(config) hosts = lb.host_list.hosts expect(hosts.length).to eq(1) expect(hosts.first) .to be_instance_of(Gitlab::Database::LoadBalancing::PrimaryHost) end + + it 'sets the name of the connection that is used' do + config = + Gitlab::Database::LoadBalancing::Configuration.new(ActiveRecord::Base) + lb = described_class.new(config) + + expect(lb.name).to eq(:main) + end end describe '#read' do @@ -140,10 +151,13 @@ RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do .to yield_with_args(ActiveRecord::Base.retrieve_connection) end - it 'uses the primary when the primary_only option is enabled' do + it 'uses the primary when load balancing is disabled' do config = Gitlab::Database::LoadBalancing::Configuration .new(ActiveRecord::Base) - lb = described_class.new(config, primary_only: true) + + allow(config).to receive(:load_balancing_enabled?).and_return(false) + + lb = described_class.new(config) # When no hosts are configured, we don't want to produce any warnings, as # they aren't useful/too noisy. @@ -274,34 +288,43 @@ RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do expect { lb.retry_with_backoff { raise } }.to raise_error(RuntimeError) end - end - describe '#connection_error?' do - before do - stub_const('Gitlab::Database::LoadBalancing::LoadBalancer::CONNECTION_ERRORS', - [NotImplementedError]) + it 'skips retries when only the primary is used' do + allow(lb).to receive(:primary_only?).and_return(true) + + expect(lb).not_to receive(:sleep) + + expect { lb.retry_with_backoff { raise } }.to raise_error(RuntimeError) end + end + describe '#connection_error?' do it 'returns true for a connection error' do - error = NotImplementedError.new + error = ActiveRecord::ConnectionNotEstablished.new expect(lb.connection_error?(error)).to eq(true) end + it 'returns false for a missing database error' do + error = ActiveRecord::NoDatabaseError.new + + expect(lb.connection_error?(error)).to eq(false) + end + it 'returns true for a wrapped connection error' do - wrapped = wrapped_exception(ActiveRecord::StatementInvalid, NotImplementedError) + wrapped = wrapped_exception(ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished) expect(lb.connection_error?(wrapped)).to eq(true) end it 'returns true for a wrapped connection error from a view' do - wrapped = wrapped_exception(ActionView::Template::Error, NotImplementedError) + wrapped = wrapped_exception(ActionView::Template::Error, ActiveRecord::ConnectionNotEstablished) expect(lb.connection_error?(wrapped)).to eq(true) end it 'returns true for deeply wrapped/nested errors' do - top = twice_wrapped_exception(ActionView::Template::Error, ActiveRecord::StatementInvalid, NotImplementedError) + top = twice_wrapped_exception(ActionView::Template::Error, ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished) expect(lb.connection_error?(top)).to eq(true) end diff --git a/spec/lib/gitlab/database/load_balancing/primary_host_spec.rb b/spec/lib/gitlab/database/load_balancing/primary_host_spec.rb index a0e63a7ee4e..45d81808971 100644 --- a/spec/lib/gitlab/database/load_balancing/primary_host_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/primary_host_spec.rb @@ -63,9 +63,8 @@ RSpec.describe Gitlab::Database::LoadBalancing::PrimaryHost do end describe '#primary_write_location' do - it 'returns the write location of the primary' do - expect(host.primary_write_location).to be_an_instance_of(String) - expect(host.primary_write_location).not_to be_empty + it 'raises NotImplementedError' do + expect { host.primary_write_location }.to raise_error(NotImplementedError) end end @@ -76,51 +75,8 @@ RSpec.describe Gitlab::Database::LoadBalancing::PrimaryHost do end describe '#database_replica_location' do - let(:connection) { double(:connection) } - - it 'returns the write ahead location of the replica', :aggregate_failures do - expect(host) - .to receive(:query_and_release) - .and_return({ 'location' => '0/D525E3A8' }) - - expect(host.database_replica_location).to be_an_instance_of(String) - end - - it 'returns nil when the database query returned no rows' do - expect(host).to receive(:query_and_release).and_return({}) - - expect(host.database_replica_location).to be_nil - end - - it 'returns nil when the database connection fails' do - allow(host).to receive(:connection).and_raise(PG::Error) - - expect(host.database_replica_location).to be_nil - end - end - - describe '#query_and_release' do - it 'executes a SQL query' do - results = host.query_and_release('SELECT 10 AS number') - - expect(results).to be_an_instance_of(Hash) - expect(results['number'].to_i).to eq(10) - end - - it 'releases the connection after running the query' do - expect(host) - .to receive(:release_connection) - .once - - host.query_and_release('SELECT 10 AS number') - end - - it 'returns an empty Hash in the event of an error' do - expect(host.connection) - .to receive(:select_all) - .and_raise(RuntimeError, 'kittens') - - expect(host.query_and_release('SELECT 10 AS number')).to eq({}) + it 'raises NotImplementedError' do + expect { host.database_replica_location }.to raise_error(NotImplementedError) end end end diff --git a/spec/lib/gitlab/database/load_balancing/rack_middleware_spec.rb b/spec/lib/gitlab/database/load_balancing/rack_middleware_spec.rb index ea0c7f781fd..af7e2a4b167 100644 --- a/spec/lib/gitlab/database/load_balancing/rack_middleware_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/rack_middleware_spec.rb @@ -6,12 +6,12 @@ RSpec.describe Gitlab::Database::LoadBalancing::RackMiddleware, :redis do let(:app) { double(:app) } let(:middleware) { described_class.new(app) } let(:warden_user) { double(:warden, user: double(:user, id: 42)) } - let(:single_sticking_object) { Set.new([[:user, 42]]) } + let(:single_sticking_object) { Set.new([[ActiveRecord::Base, :user, 42]]) } let(:multiple_sticking_objects) do Set.new([ - [:user, 42], - [:runner, '123456789'], - [:runner, '1234'] + [ActiveRecord::Base, :user, 42], + [ActiveRecord::Base, :runner, '123456789'], + [ActiveRecord::Base, :runner, '1234'] ]) end @@ -19,47 +19,6 @@ RSpec.describe Gitlab::Database::LoadBalancing::RackMiddleware, :redis do Gitlab::Database::LoadBalancing::Session.clear_session end - describe '.stick_or_unstick' do - before do - allow(Gitlab::Database::LoadBalancing).to receive(:enable?) - .and_return(true) - end - - it 'sticks or unsticks a single object and updates the Rack environment' do - expect(Gitlab::Database::LoadBalancing::Sticking) - .to receive(:unstick_or_continue_sticking) - .with(:user, 42) - - env = {} - - described_class.stick_or_unstick(env, :user, 42) - - expect(env[described_class::STICK_OBJECT].to_a).to eq([[:user, 42]]) - end - - it 'sticks or unsticks multiple objects and updates the Rack environment' do - expect(Gitlab::Database::LoadBalancing::Sticking) - .to receive(:unstick_or_continue_sticking) - .with(:user, 42) - .ordered - - expect(Gitlab::Database::LoadBalancing::Sticking) - .to receive(:unstick_or_continue_sticking) - .with(:runner, '123456789') - .ordered - - env = {} - - described_class.stick_or_unstick(env, :user, 42) - described_class.stick_or_unstick(env, :runner, '123456789') - - expect(env[described_class::STICK_OBJECT].to_a).to eq([ - [:user, 42], - [:runner, '123456789'] - ]) - end - end - describe '#call' do it 'handles a request' do env = {} @@ -82,7 +41,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::RackMiddleware, :redis do describe '#unstick_or_continue_sticking' do it 'does not stick if no namespace and identifier could be found' do - expect(Gitlab::Database::LoadBalancing::Sticking) + expect(ApplicationRecord.sticking) .not_to receive(:unstick_or_continue_sticking) middleware.unstick_or_continue_sticking({}) @@ -91,9 +50,11 @@ RSpec.describe Gitlab::Database::LoadBalancing::RackMiddleware, :redis do it 'sticks to the primary if a warden user is found' do env = { 'warden' => warden_user } - expect(Gitlab::Database::LoadBalancing::Sticking) - .to receive(:unstick_or_continue_sticking) - .with(:user, 42) + Gitlab::Database::LoadBalancing.base_models.each do |model| + expect(model.sticking) + .to receive(:unstick_or_continue_sticking) + .with(:user, 42) + end middleware.unstick_or_continue_sticking(env) end @@ -101,7 +62,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::RackMiddleware, :redis do it 'sticks to the primary if a sticking namespace and identifier is found' do env = { described_class::STICK_OBJECT => single_sticking_object } - expect(Gitlab::Database::LoadBalancing::Sticking) + expect(ApplicationRecord.sticking) .to receive(:unstick_or_continue_sticking) .with(:user, 42) @@ -111,17 +72,17 @@ RSpec.describe Gitlab::Database::LoadBalancing::RackMiddleware, :redis do it 'sticks to the primary if multiple sticking namespaces and identifiers were found' do env = { described_class::STICK_OBJECT => multiple_sticking_objects } - expect(Gitlab::Database::LoadBalancing::Sticking) + expect(ApplicationRecord.sticking) .to receive(:unstick_or_continue_sticking) .with(:user, 42) .ordered - expect(Gitlab::Database::LoadBalancing::Sticking) + expect(ApplicationRecord.sticking) .to receive(:unstick_or_continue_sticking) .with(:runner, '123456789') .ordered - expect(Gitlab::Database::LoadBalancing::Sticking) + expect(ApplicationRecord.sticking) .to receive(:unstick_or_continue_sticking) .with(:runner, '1234') .ordered @@ -132,7 +93,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::RackMiddleware, :redis do describe '#stick_if_necessary' do it 'does not stick to the primary if not necessary' do - expect(Gitlab::Database::LoadBalancing::Sticking) + expect(ApplicationRecord.sticking) .not_to receive(:stick_if_necessary) middleware.stick_if_necessary({}) @@ -141,9 +102,11 @@ RSpec.describe Gitlab::Database::LoadBalancing::RackMiddleware, :redis do it 'sticks to the primary if a warden user is found' do env = { 'warden' => warden_user } - expect(Gitlab::Database::LoadBalancing::Sticking) - .to receive(:stick_if_necessary) - .with(:user, 42) + Gitlab::Database::LoadBalancing.base_models.each do |model| + expect(model.sticking) + .to receive(:stick_if_necessary) + .with(:user, 42) + end middleware.stick_if_necessary(env) end @@ -151,7 +114,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::RackMiddleware, :redis do it 'sticks to the primary if a a single sticking object is found' do env = { described_class::STICK_OBJECT => single_sticking_object } - expect(Gitlab::Database::LoadBalancing::Sticking) + expect(ApplicationRecord.sticking) .to receive(:stick_if_necessary) .with(:user, 42) @@ -161,17 +124,17 @@ RSpec.describe Gitlab::Database::LoadBalancing::RackMiddleware, :redis do it 'sticks to the primary if multiple sticking namespaces and identifiers were found' do env = { described_class::STICK_OBJECT => multiple_sticking_objects } - expect(Gitlab::Database::LoadBalancing::Sticking) + expect(ApplicationRecord.sticking) .to receive(:stick_if_necessary) .with(:user, 42) .ordered - expect(Gitlab::Database::LoadBalancing::Sticking) + expect(ApplicationRecord.sticking) .to receive(:stick_if_necessary) .with(:runner, '123456789') .ordered - expect(Gitlab::Database::LoadBalancing::Sticking) + expect(ApplicationRecord.sticking) .to receive(:stick_if_necessary) .with(:runner, '1234') .ordered @@ -182,47 +145,34 @@ RSpec.describe Gitlab::Database::LoadBalancing::RackMiddleware, :redis do describe '#clear' do it 'clears the currently used host and session' do - lb = double(:lb) session = spy(:session) - allow(middleware).to receive(:load_balancer).and_return(lb) - - expect(lb).to receive(:release_host) - stub_const('Gitlab::Database::LoadBalancing::Session', session) + expect(Gitlab::Database::LoadBalancing).to receive(:release_hosts) + middleware.clear expect(session).to have_received(:clear_session) end end - describe '.load_balancer' do - it 'returns a the load balancer' do - proxy = double(:proxy) - - expect(Gitlab::Database::LoadBalancing).to receive(:proxy) - .and_return(proxy) - - expect(proxy).to receive(:load_balancer) - - middleware.load_balancer - end - end - - describe '#sticking_namespaces_and_ids' do + describe '#sticking_namespaces' do context 'using a Warden request' do it 'returns the warden user if present' do env = { 'warden' => warden_user } + ids = Gitlab::Database::LoadBalancing.base_models.map do |model| + [model, :user, 42] + end - expect(middleware.sticking_namespaces_and_ids(env)).to eq([[:user, 42]]) + expect(middleware.sticking_namespaces(env)).to eq(ids) end it 'returns an empty Array if no user was present' do warden = double(:warden, user: nil) env = { 'warden' => warden } - expect(middleware.sticking_namespaces_and_ids(env)).to eq([]) + expect(middleware.sticking_namespaces(env)).to eq([]) end end @@ -230,17 +180,17 @@ RSpec.describe Gitlab::Database::LoadBalancing::RackMiddleware, :redis do it 'returns the sticking object' do env = { described_class::STICK_OBJECT => multiple_sticking_objects } - expect(middleware.sticking_namespaces_and_ids(env)).to eq([ - [:user, 42], - [:runner, '123456789'], - [:runner, '1234'] + expect(middleware.sticking_namespaces(env)).to eq([ + [ActiveRecord::Base, :user, 42], + [ActiveRecord::Base, :runner, '123456789'], + [ActiveRecord::Base, :runner, '1234'] ]) end end context 'using a regular request' do it 'returns an empty Array' do - expect(middleware.sticking_namespaces_and_ids({})).to eq([]) + expect(middleware.sticking_namespaces({})).to eq([]) end end end diff --git a/spec/lib/gitlab/database/load_balancing/setup_spec.rb b/spec/lib/gitlab/database/load_balancing/setup_spec.rb new file mode 100644 index 00000000000..01646bc76ef --- /dev/null +++ b/spec/lib/gitlab/database/load_balancing/setup_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::LoadBalancing::Setup do + describe '#setup' do + it 'sets up the load balancer' do + setup = described_class.new(ActiveRecord::Base) + + expect(setup).to receive(:disable_prepared_statements) + expect(setup).to receive(:setup_load_balancer) + expect(setup).to receive(:setup_service_discovery) + + setup.setup + end + end + + describe '#disable_prepared_statements' do + it 'disables prepared statements and reconnects to the database' do + config = double( + :config, + configuration_hash: { host: 'localhost' }, + env_name: 'test', + name: 'main' + ) + model = double(:model, connection_db_config: config) + + expect(ActiveRecord::DatabaseConfigurations::HashConfig) + .to receive(:new) + .with('test', 'main', { host: 'localhost', prepared_statements: false }) + .and_call_original + + # HashConfig doesn't implement its own #==, so we can't directly compare + # the expected value with a pre-defined one. + expect(model) + .to receive(:establish_connection) + .with(an_instance_of(ActiveRecord::DatabaseConfigurations::HashConfig)) + + described_class.new(model).disable_prepared_statements + end + end + + describe '#setup_load_balancer' do + it 'sets up the load balancer' do + model = Class.new(ActiveRecord::Base) + setup = described_class.new(model) + config = Gitlab::Database::LoadBalancing::Configuration.new(model) + lb = instance_spy(Gitlab::Database::LoadBalancing::LoadBalancer) + + allow(lb).to receive(:configuration).and_return(config) + + expect(Gitlab::Database::LoadBalancing::LoadBalancer) + .to receive(:new) + .with(setup.configuration) + .and_return(lb) + + setup.setup_load_balancer + + expect(model.connection.load_balancer).to eq(lb) + expect(model.sticking) + .to be_an_instance_of(Gitlab::Database::LoadBalancing::Sticking) + end + end + + describe '#setup_service_discovery' do + context 'when service discovery is disabled' do + it 'does nothing' do + expect(Gitlab::Database::LoadBalancing::ServiceDiscovery) + .not_to receive(:new) + + described_class.new(ActiveRecord::Base).setup_service_discovery + end + end + + context 'when service discovery is enabled' do + it 'immediately performs service discovery' do + model = ActiveRecord::Base + setup = described_class.new(model) + sv = instance_spy(Gitlab::Database::LoadBalancing::ServiceDiscovery) + lb = model.connection.load_balancer + + allow(setup.configuration) + .to receive(:service_discovery_enabled?) + .and_return(true) + + allow(Gitlab::Database::LoadBalancing::ServiceDiscovery) + .to receive(:new) + .with(lb, setup.configuration.service_discovery) + .and_return(sv) + + expect(sv).to receive(:perform_service_discovery) + expect(sv).not_to receive(:start) + + setup.setup_service_discovery + end + + it 'starts service discovery if needed' do + model = ActiveRecord::Base + setup = described_class.new(model, start_service_discovery: true) + sv = instance_spy(Gitlab::Database::LoadBalancing::ServiceDiscovery) + lb = model.connection.load_balancer + + allow(setup.configuration) + .to receive(:service_discovery_enabled?) + .and_return(true) + + allow(Gitlab::Database::LoadBalancing::ServiceDiscovery) + .to receive(:new) + .with(lb, setup.configuration.service_discovery) + .and_return(sv) + + expect(sv).to receive(:perform_service_discovery) + expect(sv).to receive(:start) + + setup.setup_service_discovery + end + end + end +end diff --git a/spec/lib/gitlab/database/load_balancing/sidekiq_client_middleware_spec.rb b/spec/lib/gitlab/database/load_balancing/sidekiq_client_middleware_spec.rb index f683ade978a..08dd6a0a788 100644 --- a/spec/lib/gitlab/database/load_balancing/sidekiq_client_middleware_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/sidekiq_client_middleware_spec.rb @@ -5,14 +5,12 @@ require 'spec_helper' RSpec.describe Gitlab::Database::LoadBalancing::SidekiqClientMiddleware do let(:middleware) { described_class.new } - let(:load_balancer) { double.as_null_object } let(:worker_class) { 'TestDataConsistencyWorker' } let(:job) { { "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e" } } before do skip_feature_flags_yaml_validation skip_default_enabled_yaml_check - allow(::Gitlab::Database::LoadBalancing).to receive_message_chain(:proxy, :load_balancer).and_return(load_balancer) end after do @@ -23,7 +21,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqClientMiddleware do middleware.call(worker_class, job, nil, nil) {} end - describe '#call' do + describe '#call', :database_replica do shared_context 'data consistency worker class' do |data_consistency, feature_flag| let(:expected_consistency) { data_consistency } let(:worker_class) do @@ -85,9 +83,15 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqClientMiddleware do end it 'passes database_replica_location' do - expected_location = { Gitlab::Database::MAIN_DATABASE_NAME.to_sym => location } + expected_location = {} - expect(load_balancer).to receive_message_chain(:host, "database_replica_location").and_return(location) + Gitlab::Database::LoadBalancing.each_load_balancer do |lb| + expect(lb.host) + .to receive(:database_replica_location) + .and_return(location) + + expected_location[lb.name] = location + end run_middleware @@ -103,9 +107,15 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqClientMiddleware do end it 'passes primary write location', :aggregate_failures do - expected_location = { Gitlab::Database::MAIN_DATABASE_NAME.to_sym => location } + expected_location = {} - expect(load_balancer).to receive(:primary_write_location).and_return(location) + Gitlab::Database::LoadBalancing.each_load_balancer do |lb| + expect(lb) + .to receive(:primary_write_location) + .and_return(location) + + expected_location[lb.name] = location + end run_middleware @@ -137,8 +147,10 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqClientMiddleware do let(:job) { { "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", 'wal_locations' => wal_locations } } before do - allow(load_balancer).to receive(:primary_write_location).and_return(new_location) - allow(load_balancer).to receive(:database_replica_location).and_return(new_location) + Gitlab::Database::LoadBalancing.each_load_balancer do |lb| + allow(lb).to receive(:primary_write_location).and_return(new_location) + allow(lb).to receive(:database_replica_location).and_return(new_location) + end end shared_examples_for 'does not set database location again' do |use_primary| diff --git a/spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb b/spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb index 9f23eb0094f..06efdcd8f99 100644 --- a/spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb @@ -2,20 +2,17 @@ require 'spec_helper' -RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware do +RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware, :clean_gitlab_redis_queues do let(:middleware) { described_class.new } - - let(:load_balancer) { double.as_null_object } - let(:worker) { worker_class.new } let(:job) { { "retry" => 3, "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", 'database_replica_location' => '0/D525E3A8' } } before do skip_feature_flags_yaml_validation skip_default_enabled_yaml_check - allow(::Gitlab::Database::LoadBalancing).to receive_message_chain(:proxy, :load_balancer).and_return(load_balancer) replication_lag!(false) + Gitlab::Database::LoadBalancing::Session.clear_session end after do @@ -67,7 +64,10 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware do let(:wal_locations) { { Gitlab::Database::MAIN_DATABASE_NAME.to_sym => location } } it 'does not stick to the primary', :aggregate_failures do - expect(load_balancer).to receive(:select_up_to_date_host).with(location).and_return(true) + expect(ActiveRecord::Base.connection.load_balancer) + .to receive(:select_up_to_date_host) + .with(location) + .and_return(true) run_middleware do expect(Gitlab::Database::LoadBalancing::Session.current.use_primary?).not_to be_truthy @@ -92,7 +92,12 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware do let(:job) { { 'job_id' => 'a180b47c-3fd6-41b8-81e9-34da61c3400e', 'wal_locations' => wal_locations } } before do - allow(load_balancer).to receive(:select_up_to_date_host).with(location).and_return(true) + Gitlab::Database::LoadBalancing.each_load_balancer do |lb| + allow(lb) + .to receive(:select_up_to_date_host) + .with(location) + .and_return(true) + end end it_behaves_like 'replica is up to date', 'replica' @@ -102,7 +107,10 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware do let(:job) { { 'job_id' => 'a180b47c-3fd6-41b8-81e9-34da61c3400e', 'dedup_wal_locations' => wal_locations } } before do - allow(load_balancer).to receive(:select_up_to_date_host).with(wal_locations[:main]).and_return(true) + allow(ActiveRecord::Base.connection.load_balancer) + .to receive(:select_up_to_date_host) + .with(wal_locations[:main]) + .and_return(true) end it_behaves_like 'replica is up to date', 'replica' @@ -112,7 +120,10 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware do let(:job) { { 'job_id' => 'a180b47c-3fd6-41b8-81e9-34da61c3400e', 'database_write_location' => '0/D525E3A8' } } before do - allow(load_balancer).to receive(:select_up_to_date_host).with('0/D525E3A8').and_return(true) + allow(ActiveRecord::Base.connection.load_balancer) + .to receive(:select_up_to_date_host) + .with('0/D525E3A8') + .and_return(true) end it_behaves_like 'replica is up to date', 'replica' @@ -158,18 +169,15 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware do process_job(job) end.to raise_error(Sidekiq::JobRetry::Skip) - expect(job['error_class']).to eq('Gitlab::Database::LoadBalancing::SidekiqServerMiddleware::JobReplicaNotUpToDate') + job_for_retry = Sidekiq::RetrySet.new.first + expect(job_for_retry['error_class']).to eq('Gitlab::Database::LoadBalancing::SidekiqServerMiddleware::JobReplicaNotUpToDate') end include_examples 'load balancing strategy', 'retry' end context 'when job is retried' do - before do - expect do - process_job(job) - end.to raise_error(Sidekiq::JobRetry::Skip) - end + let(:job) { { "retry" => 3, "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", 'database_replica_location' => '0/D525E3A8', 'retry_count' => 0 } } context 'and replica still lagging behind' do include_examples 'stick to the primary', 'primary' @@ -191,7 +199,9 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware do context 'when replica is not up to date' do before do - allow(load_balancer).to receive(:select_up_to_date_host).and_return(false) + Gitlab::Database::LoadBalancing.each_load_balancer do |lb| + allow(lb).to receive(:select_up_to_date_host).and_return(false) + end end include_examples 'stick to the primary', 'primary' @@ -199,8 +209,47 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware do end end + describe '#databases_in_sync?' do + it 'treats load balancers without WAL entries as in sync' do + expect(middleware.send(:databases_in_sync?, {})) + .to eq(true) + end + + it 'returns true when all load balancers are in sync' do + locations = {} + + Gitlab::Database::LoadBalancing.each_load_balancer do |lb| + locations[lb.name] = 'foo' + + expect(lb) + .to receive(:select_up_to_date_host) + .with('foo') + .and_return(true) + end + + expect(middleware.send(:databases_in_sync?, locations)) + .to eq(true) + end + + it 'returns false when the load balancers are not in sync' do + locations = {} + + Gitlab::Database::LoadBalancing.each_load_balancer do |lb| + locations[lb.name] = 'foo' + + allow(lb) + .to receive(:select_up_to_date_host) + .with('foo') + .and_return(false) + end + + expect(middleware.send(:databases_in_sync?, locations)) + .to eq(false) + end + end + def process_job(job) - Sidekiq::JobRetry.new.local(worker_class, job, 'default') do + Sidekiq::JobRetry.new.local(worker_class, job.to_json, 'default') do worker_class.process_job(job) end end @@ -212,6 +261,8 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware do end def replication_lag!(exists) - allow(load_balancer).to receive(:select_up_to_date_host).and_return(!exists) + Gitlab::Database::LoadBalancing.each_load_balancer do |lb| + allow(lb).to receive(:select_up_to_date_host).and_return(!exists) + end end end diff --git a/spec/lib/gitlab/database/load_balancing/sticking_spec.rb b/spec/lib/gitlab/database/load_balancing/sticking_spec.rb index cf52e59db3a..8ceda52ee85 100644 --- a/spec/lib/gitlab/database/load_balancing/sticking_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/sticking_spec.rb @@ -3,55 +3,82 @@ require 'spec_helper' RSpec.describe Gitlab::Database::LoadBalancing::Sticking, :redis do + let(:sticking) do + described_class.new(ActiveRecord::Base.connection.load_balancer) + end + after do Gitlab::Database::LoadBalancing::Session.clear_session end - describe '.stick_if_necessary' do - context 'when sticking is disabled' do - it 'does not perform any sticking' do - expect(described_class).not_to receive(:stick) + describe '#stick_or_unstick_request' do + it 'sticks or unsticks a single object and updates the Rack environment' do + expect(sticking) + .to receive(:unstick_or_continue_sticking) + .with(:user, 42) - described_class.stick_if_necessary(:user, 42) - end + env = {} + + sticking.stick_or_unstick_request(env, :user, 42) + + expect(env[Gitlab::Database::LoadBalancing::RackMiddleware::STICK_OBJECT].to_a) + .to eq([[ActiveRecord::Base, :user, 42]]) end - context 'when sticking is enabled' do - before do - allow(Gitlab::Database::LoadBalancing).to receive(:enable?) - .and_return(true) - end + it 'sticks or unsticks multiple objects and updates the Rack environment' do + expect(sticking) + .to receive(:unstick_or_continue_sticking) + .with(:user, 42) + .ordered - it 'does not stick if no write was performed' do - allow(Gitlab::Database::LoadBalancing::Session.current) - .to receive(:performed_write?) - .and_return(false) + expect(sticking) + .to receive(:unstick_or_continue_sticking) + .with(:runner, '123456789') + .ordered - expect(described_class).not_to receive(:stick) + env = {} - described_class.stick_if_necessary(:user, 42) - end + sticking.stick_or_unstick_request(env, :user, 42) + sticking.stick_or_unstick_request(env, :runner, '123456789') - it 'sticks to the primary if a write was performed' do - allow(Gitlab::Database::LoadBalancing::Session.current) - .to receive(:performed_write?) - .and_return(true) + expect(env[Gitlab::Database::LoadBalancing::RackMiddleware::STICK_OBJECT].to_a).to eq([ + [ActiveRecord::Base, :user, 42], + [ActiveRecord::Base, :runner, '123456789'] + ]) + end + end - expect(described_class).to receive(:stick).with(:user, 42) + describe '#stick_if_necessary' do + it 'does not stick if no write was performed' do + allow(Gitlab::Database::LoadBalancing::Session.current) + .to receive(:performed_write?) + .and_return(false) - described_class.stick_if_necessary(:user, 42) - end + expect(sticking).not_to receive(:stick) + + sticking.stick_if_necessary(:user, 42) + end + + it 'sticks to the primary if a write was performed' do + allow(Gitlab::Database::LoadBalancing::Session.current) + .to receive(:performed_write?) + .and_return(true) + + expect(sticking) + .to receive(:stick) + .with(:user, 42) + + sticking.stick_if_necessary(:user, 42) end end - describe '.all_caught_up?' do - let(:lb) { double(:lb) } + describe '#all_caught_up?' do + let(:lb) { ActiveRecord::Base.connection.load_balancer } let(:last_write_location) { 'foo' } before do - allow(described_class).to receive(:load_balancer).and_return(lb) - - allow(described_class).to receive(:last_write_location_for) + allow(sticking) + .to receive(:last_write_location_for) .with(:user, 42) .and_return(last_write_location) end @@ -60,13 +87,9 @@ RSpec.describe Gitlab::Database::LoadBalancing::Sticking, :redis do let(:last_write_location) { nil } it 'returns true' do - allow(described_class).to receive(:last_write_location_for) - .with(:user, 42) - .and_return(nil) - expect(lb).not_to receive(:select_up_to_date_host) - expect(described_class.all_caught_up?(:user, 42)).to eq(true) + expect(sticking.all_caught_up?(:user, 42)).to eq(true) end end @@ -76,9 +99,11 @@ RSpec.describe Gitlab::Database::LoadBalancing::Sticking, :redis do end it 'returns true, and unsticks' do - expect(described_class).to receive(:unstick).with(:user, 42) + expect(sticking) + .to receive(:unstick) + .with(:user, 42) - expect(described_class.all_caught_up?(:user, 42)).to eq(true) + expect(sticking.all_caught_up?(:user, 42)).to eq(true) end it 'notifies with the proper event payload' do @@ -87,7 +112,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::Sticking, :redis do .with('caught_up_replica_pick.load_balancing', { result: true }) .and_call_original - described_class.all_caught_up?(:user, 42) + sticking.all_caught_up?(:user, 42) end end @@ -97,7 +122,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::Sticking, :redis do end it 'returns false' do - expect(described_class.all_caught_up?(:user, 42)).to eq(false) + expect(sticking.all_caught_up?(:user, 42)).to eq(false) end it 'notifies with the proper event payload' do @@ -106,42 +131,43 @@ RSpec.describe Gitlab::Database::LoadBalancing::Sticking, :redis do .with('caught_up_replica_pick.load_balancing', { result: false }) .and_call_original - described_class.all_caught_up?(:user, 42) + sticking.all_caught_up?(:user, 42) end end end - describe '.unstick_or_continue_sticking' do - let(:lb) { double(:lb) } - - before do - allow(described_class).to receive(:load_balancer).and_return(lb) - end + describe '#unstick_or_continue_sticking' do + let(:lb) { ActiveRecord::Base.connection.load_balancer } it 'simply returns if no write location could be found' do - allow(described_class).to receive(:last_write_location_for) + allow(sticking) + .to receive(:last_write_location_for) .with(:user, 42) .and_return(nil) expect(lb).not_to receive(:select_up_to_date_host) - described_class.unstick_or_continue_sticking(:user, 42) + sticking.unstick_or_continue_sticking(:user, 42) end it 'unsticks if all secondaries have caught up' do - allow(described_class).to receive(:last_write_location_for) + allow(sticking) + .to receive(:last_write_location_for) .with(:user, 42) .and_return('foo') allow(lb).to receive(:select_up_to_date_host).with('foo').and_return(true) - expect(described_class).to receive(:unstick).with(:user, 42) + expect(sticking) + .to receive(:unstick) + .with(:user, 42) - described_class.unstick_or_continue_sticking(:user, 42) + sticking.unstick_or_continue_sticking(:user, 42) end it 'continues using the primary if the secondaries have not yet caught up' do - allow(described_class).to receive(:last_write_location_for) + allow(sticking) + .to receive(:last_write_location_for) .with(:user, 42) .and_return('foo') @@ -150,184 +176,151 @@ RSpec.describe Gitlab::Database::LoadBalancing::Sticking, :redis do expect(Gitlab::Database::LoadBalancing::Session.current) .to receive(:use_primary!) - described_class.unstick_or_continue_sticking(:user, 42) + sticking.unstick_or_continue_sticking(:user, 42) end end RSpec.shared_examples 'sticking' do - context 'when sticking is disabled' do - it 'does not perform any sticking', :aggregate_failures do - expect(described_class).not_to receive(:set_write_location_for) - expect(Gitlab::Database::LoadBalancing::Session.current).not_to receive(:use_primary!) - - described_class.bulk_stick(:user, ids) - end + before do + allow(ActiveRecord::Base.connection.load_balancer) + .to receive(:primary_write_location) + .and_return('foo') end - context 'when sticking is enabled' do - before do - allow(Gitlab::Database::LoadBalancing).to receive(:configured?).and_return(true) - - lb = double(:lb, primary_write_location: 'foo') + it 'sticks an entity to the primary', :aggregate_failures do + allow(ActiveRecord::Base.connection.load_balancer) + .to receive(:primary_only?) + .and_return(false) - allow(described_class).to receive(:load_balancer).and_return(lb) + ids.each do |id| + expect(sticking) + .to receive(:set_write_location_for) + .with(:user, id, 'foo') end - it 'sticks an entity to the primary', :aggregate_failures do - ids.each do |id| - expect(described_class).to receive(:set_write_location_for) - .with(:user, id, 'foo') - end + expect(Gitlab::Database::LoadBalancing::Session.current) + .to receive(:use_primary!) - expect(Gitlab::Database::LoadBalancing::Session.current) - .to receive(:use_primary!) + subject + end - subject - end + it 'does not update the write location when no replicas are used' do + expect(sticking).not_to receive(:set_write_location_for) + + subject end end - describe '.stick' do + describe '#stick' do it_behaves_like 'sticking' do let(:ids) { [42] } - subject { described_class.stick(:user, ids.first) } + subject { sticking.stick(:user, ids.first) } end end - describe '.bulk_stick' do + describe '#bulk_stick' do it_behaves_like 'sticking' do let(:ids) { [42, 43] } - subject { described_class.bulk_stick(:user, ids) } + subject { sticking.bulk_stick(:user, ids) } end end - describe '.mark_primary_write_location' do - context 'when enabled' do - before do - allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(true) - allow(Gitlab::Database::LoadBalancing).to receive(:configured?).and_return(true) - end - - it 'updates the write location with the load balancer' do - lb = double(:lb, primary_write_location: 'foo') + describe '#mark_primary_write_location' do + it 'updates the write location with the load balancer' do + allow(ActiveRecord::Base.connection.load_balancer) + .to receive(:primary_write_location) + .and_return('foo') - allow(described_class).to receive(:load_balancer).and_return(lb) + allow(ActiveRecord::Base.connection.load_balancer) + .to receive(:primary_only?) + .and_return(false) - expect(described_class).to receive(:set_write_location_for) - .with(:user, 42, 'foo') + expect(sticking) + .to receive(:set_write_location_for) + .with(:user, 42, 'foo') - described_class.mark_primary_write_location(:user, 42) - end + sticking.mark_primary_write_location(:user, 42) end - context 'when load balancing is configured but not enabled' do - before do - allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(false) - allow(Gitlab::Database::LoadBalancing).to receive(:configured?).and_return(true) - end - - it 'updates the write location with the main ActiveRecord connection' do - allow(described_class).to receive(:load_balancer).and_return(nil) - expect(ActiveRecord::Base).to receive(:connection).and_call_original - expect(described_class).to receive(:set_write_location_for) - .with(:user, 42, anything) + it 'does nothing when no replicas are used' do + expect(sticking).not_to receive(:set_write_location_for) - described_class.mark_primary_write_location(:user, 42) - end - - context 'when write location is nil' do - before do - allow(Gitlab::Database.main).to receive(:get_write_location).and_return(nil) - end + sticking.mark_primary_write_location(:user, 42) + end + end - it 'does not update the write location' do - expect(described_class).not_to receive(:set_write_location_for) + describe '#unstick' do + it 'removes the sticking data from Redis' do + sticking.set_write_location_for(:user, 4, 'foo') + sticking.unstick(:user, 4) - described_class.mark_primary_write_location(:user, 42) - end - end + expect(sticking.last_write_location_for(:user, 4)).to be_nil end - context 'when load balancing is disabled' do - before do - allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(false) - allow(Gitlab::Database::LoadBalancing).to receive(:configured?).and_return(false) + it 'removes the old key' do + Gitlab::Redis::SharedState.with do |redis| + redis.set(sticking.send(:old_redis_key_for, :user, 4), 'foo', ex: 30) end - it 'updates the write location with the main ActiveRecord connection' do - expect(described_class).not_to receive(:set_write_location_for) - - described_class.mark_primary_write_location(:user, 42) - end + sticking.unstick(:user, 4) + expect(sticking.last_write_location_for(:user, 4)).to be_nil end end - describe '.unstick' do - it 'removes the sticking data from Redis' do - described_class.set_write_location_for(:user, 4, 'foo') - described_class.unstick(:user, 4) + describe '#last_write_location_for' do + it 'returns the last WAL write location for a user' do + sticking.set_write_location_for(:user, 4, 'foo') - expect(described_class.last_write_location_for(:user, 4)).to be_nil + expect(sticking.last_write_location_for(:user, 4)).to eq('foo') end - end - describe '.last_write_location_for' do - it 'returns the last WAL write location for a user' do - described_class.set_write_location_for(:user, 4, 'foo') + it 'falls back to reading the old key' do + Gitlab::Redis::SharedState.with do |redis| + redis.set(sticking.send(:old_redis_key_for, :user, 4), 'foo', ex: 30) + end - expect(described_class.last_write_location_for(:user, 4)).to eq('foo') + expect(sticking.last_write_location_for(:user, 4)).to eq('foo') end end - describe '.redis_key_for' do + describe '#redis_key_for' do it 'returns a String' do - expect(described_class.redis_key_for(:user, 42)) - .to eq('database-load-balancing/write-location/user/42') + expect(sticking.redis_key_for(:user, 42)) + .to eq('database-load-balancing/write-location/main/user/42') end end - describe '.load_balancer' do - it 'returns a the load balancer' do - proxy = double(:proxy) - - expect(Gitlab::Database::LoadBalancing).to receive(:proxy) - .and_return(proxy) - - expect(proxy).to receive(:load_balancer) - - described_class.load_balancer - end - end - - describe '.select_caught_up_replicas' do - let(:lb) { double(:lb) } - - before do - allow(described_class).to receive(:load_balancer).and_return(lb) - end + describe '#select_caught_up_replicas' do + let(:lb) { ActiveRecord::Base.connection.load_balancer } context 'with no write location' do before do - allow(described_class).to receive(:last_write_location_for) - .with(:project, 42).and_return(nil) + allow(sticking) + .to receive(:last_write_location_for) + .with(:project, 42) + .and_return(nil) end it 'returns false and does not try to find caught up hosts' do expect(lb).not_to receive(:select_up_to_date_host) - expect(described_class.select_caught_up_replicas(:project, 42)).to be false + expect(sticking.select_caught_up_replicas(:project, 42)).to be false end end context 'with write location' do before do - allow(described_class).to receive(:last_write_location_for) - .with(:project, 42).and_return('foo') + allow(sticking) + .to receive(:last_write_location_for) + .with(:project, 42) + .and_return('foo') end it 'returns true, selects hosts, and unsticks if any secondary has caught up' do expect(lb).to receive(:select_up_to_date_host).and_return(true) - expect(described_class).to receive(:unstick).with(:project, 42) - expect(described_class.select_caught_up_replicas(:project, 42)).to be true + expect(sticking) + .to receive(:unstick) + .with(:project, 42) + expect(sticking.select_caught_up_replicas(:project, 42)).to be true end end end diff --git a/spec/lib/gitlab/database/load_balancing_spec.rb b/spec/lib/gitlab/database/load_balancing_spec.rb index f40ad444081..bf5314e2c34 100644 --- a/spec/lib/gitlab/database/load_balancing_spec.rb +++ b/spec/lib/gitlab/database/load_balancing_spec.rb @@ -3,203 +3,52 @@ require 'spec_helper' RSpec.describe Gitlab::Database::LoadBalancing do - describe '.proxy' do - before do - @previous_proxy = ActiveRecord::Base.load_balancing_proxy + describe '.base_models' do + it 'returns the models to apply load balancing to' do + models = described_class.base_models - ActiveRecord::Base.load_balancing_proxy = connection_proxy - end - - after do - ActiveRecord::Base.load_balancing_proxy = @previous_proxy - end - - context 'when configured' do - let(:connection_proxy) { double(:connection_proxy) } - - it 'returns the connection proxy' do - expect(subject.proxy).to eq(connection_proxy) - end - end - - context 'when not configured' do - let(:connection_proxy) { nil } + expect(models).to include(ActiveRecord::Base) - it 'returns nil' do - expect(subject.proxy).to be_nil + if Gitlab::Database.has_config?(:ci) + expect(models).to include(Ci::CiDatabaseRecord) end - - it 'tracks an error to sentry' do - expect(Gitlab::ErrorTracking).to receive(:track_exception).with( - an_instance_of(subject::ProxyNotConfiguredError) - ) - - subject.proxy - end - end - end - - describe '.configuration' do - it 'returns the configuration for the load balancer' do - raw = ActiveRecord::Base.connection_db_config.configuration_hash - cfg = described_class.configuration - - # There isn't much to test here as the load balancing settings might not - # (and likely aren't) set when running tests. - expect(cfg.pool_size).to eq(raw[:pool]) - end - end - - describe '.enable?' do - before do - allow(described_class.configuration) - .to receive(:hosts) - .and_return(%w(foo)) - end - - it 'returns false when no hosts are specified' do - allow(described_class.configuration).to receive(:hosts).and_return([]) - - expect(described_class.enable?).to eq(false) - end - - it 'returns true when Sidekiq is being used' do - allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(true) - - expect(described_class.enable?).to eq(true) - end - - it 'returns false when running inside a Rake task' do - allow(Gitlab::Runtime).to receive(:rake?).and_return(true) - - expect(described_class.enable?).to eq(false) - end - - it 'returns true when load balancing should be enabled' do - allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(false) - - expect(described_class.enable?).to eq(true) end - it 'returns true when service discovery is enabled' do - allow(described_class.configuration).to receive(:hosts).and_return([]) - allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(false) - - allow(described_class.configuration) - .to receive(:service_discovery_enabled?) - .and_return(true) - - expect(described_class.enable?).to eq(true) + it 'returns the models as a frozen array' do + expect(described_class.base_models).to be_frozen end end - describe '.configured?' do - it 'returns true when hosts are configured' do - allow(described_class.configuration) - .to receive(:hosts) - .and_return(%w[foo]) - - expect(described_class.configured?).to eq(true) - end + describe '.each_load_balancer' do + it 'yields every load balancer to the supplied block' do + lbs = [] - it 'returns true when service discovery is enabled' do - allow(described_class.configuration).to receive(:hosts).and_return([]) - allow(described_class.configuration) - .to receive(:service_discovery_enabled?) - .and_return(true) + described_class.each_load_balancer do |lb| + lbs << lb + end - expect(described_class.configured?).to eq(true) + expect(lbs.length).to eq(described_class.base_models.length) end - it 'returns false when neither service discovery nor hosts are configured' do - allow(described_class.configuration).to receive(:hosts).and_return([]) - allow(described_class.configuration) - .to receive(:service_discovery_enabled?) - .and_return(false) + it 'returns an Enumerator when no block is given' do + res = described_class.each_load_balancer - expect(described_class.configured?).to eq(false) + expect(res.next) + .to be_an_instance_of(Gitlab::Database::LoadBalancing::LoadBalancer) end end - describe '.configure_proxy' do - before do - allow(ActiveRecord::Base).to receive(:load_balancing_proxy=) - end - - it 'configures the connection proxy' do - described_class.configure_proxy - - expect(ActiveRecord::Base).to have_received(:load_balancing_proxy=) - .with(Gitlab::Database::LoadBalancing::ConnectionProxy) - end - - context 'when service discovery is enabled' do - it 'runs initial service discovery when configuring the connection proxy' do - discover = instance_spy(Gitlab::Database::LoadBalancing::ServiceDiscovery) - - allow(described_class.configuration) - .to receive(:service_discovery) - .and_return({ record: 'foo' }) - - expect(Gitlab::Database::LoadBalancing::ServiceDiscovery) - .to receive(:new) - .with( - an_instance_of(Gitlab::Database::LoadBalancing::LoadBalancer), - an_instance_of(Hash) - ) - .and_return(discover) - - expect(discover).to receive(:perform_service_discovery) - - described_class.configure_proxy + describe '.release_hosts' do + it 'releases the host of every load balancer' do + described_class.each_load_balancer do |lb| + expect(lb).to receive(:release_host) end - end - end - - describe '.start_service_discovery' do - it 'does not start if service discovery is disabled' do - expect(Gitlab::Database::LoadBalancing::ServiceDiscovery) - .not_to receive(:new) - described_class.start_service_discovery - end - - it 'starts service discovery if enabled' do - allow(described_class.configuration) - .to receive(:service_discovery_enabled?) - .and_return(true) - - instance = double(:instance) - config = Gitlab::Database::LoadBalancing::Configuration - .new(ActiveRecord::Base) - lb = Gitlab::Database::LoadBalancing::LoadBalancer.new(config) - proxy = Gitlab::Database::LoadBalancing::ConnectionProxy.new(lb) - - allow(described_class) - .to receive(:proxy) - .and_return(proxy) - - expect(Gitlab::Database::LoadBalancing::ServiceDiscovery) - .to receive(:new) - .with(lb, an_instance_of(Hash)) - .and_return(instance) - - expect(instance) - .to receive(:start) - - described_class.start_service_discovery + described_class.release_hosts end end describe '.db_role_for_connection' do - context 'when the load balancing is not configured' do - let(:connection) { ActiveRecord::Base.connection } - - it 'returns primary' do - expect(described_class.db_role_for_connection(connection)).to eq(:primary) - end - end - context 'when the NullPool is used for connection' do let(:pool) { ActiveRecord::ConnectionAdapters::NullPool.new } let(:connection) { double(:connection, pool: pool) } @@ -253,7 +102,7 @@ RSpec.describe Gitlab::Database::LoadBalancing do # - In each test, we listen to the SQL queries (via sql.active_record # instrumentation) while triggering real queries from the defined model. # - We assert the desinations (replica/primary) of the queries in order. - describe 'LoadBalancing integration tests', :db_load_balancing, :delete do + describe 'LoadBalancing integration tests', :database_replica, :delete do before(:all) do ActiveRecord::Schema.define do create_table :load_balancing_test, force: true do |t| @@ -274,10 +123,6 @@ RSpec.describe Gitlab::Database::LoadBalancing do end end - before do - model.singleton_class.prepend ::Gitlab::Database::LoadBalancing::ActiveRecordProxy - end - where(:queries, :include_transaction, :expected_results) do [ # Read methods diff --git a/spec/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers_spec.rb index 708d1be6e00..54b3ad22faf 100644 --- a/spec/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers_spec.rb @@ -19,6 +19,10 @@ RSpec.describe Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers do end end + after(:all) do + migration.drop_table :loose_fk_test_table + end + before do 3.times { model.create! } end @@ -45,8 +49,9 @@ RSpec.describe Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers do expect(LooseForeignKeys::DeletedRecord.count).to eq(1) deleted_record = LooseForeignKeys::DeletedRecord.all.first - expect(deleted_record.deleted_table_primary_key_value).to eq(record_to_be_deleted.id) - expect(deleted_record.deleted_table_name).to eq('loose_fk_test_table') + expect(deleted_record.primary_key_value).to eq(record_to_be_deleted.id) + expect(deleted_record.fully_qualified_table_name).to eq('public.loose_fk_test_table') + expect(deleted_record.partition).to eq(1) end it 'stores multiple record deletions' do diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 006f8a39f9c..d89af1521a2 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -1631,10 +1631,19 @@ RSpec.describe Gitlab::Database::MigrationHelpers do let(:worker) do Class.new do include Sidekiq::Worker + sidekiq_options queue: 'test' + + def self.name + 'WorkerClass' + end end end + before do + stub_const(worker.name, worker) + end + describe '#sidekiq_queue_length' do context 'when queue is empty' do it 'returns zero' do diff --git a/spec/lib/gitlab/database/migrations/instrumentation_spec.rb b/spec/lib/gitlab/database/migrations/instrumentation_spec.rb index 5945e5a2039..841d2a98a16 100644 --- a/spec/lib/gitlab/database/migrations/instrumentation_spec.rb +++ b/spec/lib/gitlab/database/migrations/instrumentation_spec.rb @@ -2,8 +2,13 @@ require 'spec_helper' RSpec.describe Gitlab::Database::Migrations::Instrumentation do + let(:result_dir) { Dir.mktmpdir } + + after do + FileUtils.rm_rf(result_dir) + end describe '#observe' do - subject { described_class.new } + subject { described_class.new(result_dir: result_dir) } let(:migration_name) { 'test' } let(:migration_version) { '12345' } @@ -13,7 +18,7 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do end context 'behavior with observers' do - subject { described_class.new([Gitlab::Database::Migrations::Observers::MigrationObserver]).observe(version: migration_version, name: migration_name) {} } + subject { described_class.new(observer_classes: [Gitlab::Database::Migrations::Observers::MigrationObserver], result_dir: result_dir).observe(version: migration_version, name: migration_name) {} } let(:observer) { instance_double('Gitlab::Database::Migrations::Observers::MigrationObserver', before: nil, after: nil, record: nil) } @@ -24,7 +29,7 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do it 'instantiates observer with observation' do expect(Gitlab::Database::Migrations::Observers::MigrationObserver) .to receive(:new) - .with(instance_of(Gitlab::Database::Migrations::Observation)) { |observation| expect(observation.version).to eq(migration_version) } + .with(instance_of(Gitlab::Database::Migrations::Observation), anything) { |observation| expect(observation.version).to eq(migration_version) } .and_return(observer) subject @@ -58,7 +63,7 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do end context 'on successful execution' do - subject { described_class.new.observe(version: migration_version, name: migration_name) {} } + subject { described_class.new(result_dir: result_dir).observe(version: migration_version, name: migration_name) {} } it 'records walltime' do expect(subject.walltime).not_to be_nil @@ -78,7 +83,7 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do end context 'upon failure' do - subject { described_class.new.observe(version: migration_version, name: migration_name) { raise 'something went wrong' } } + subject { described_class.new(result_dir: result_dir).observe(version: migration_version, name: migration_name) { raise 'something went wrong' } } it 'raises the exception' do expect { subject }.to raise_error(/something went wrong/) @@ -93,7 +98,7 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do # ignore end - let(:instance) { described_class.new } + let(:instance) { described_class.new(result_dir: result_dir) } it 'records walltime' do expect(subject.walltime).not_to be_nil @@ -114,7 +119,7 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do end context 'sequence of migrations with failures' do - subject { described_class.new } + subject { described_class.new(result_dir: result_dir) } let(:migration1) { double('migration1', call: nil) } let(:migration2) { double('migration2', call: nil) } diff --git a/spec/lib/gitlab/database/migrations/observers/query_details_spec.rb b/spec/lib/gitlab/database/migrations/observers/query_details_spec.rb index 36885a1594f..191ac29e3b3 100644 --- a/spec/lib/gitlab/database/migrations/observers/query_details_spec.rb +++ b/spec/lib/gitlab/database/migrations/observers/query_details_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' RSpec.describe Gitlab::Database::Migrations::Observers::QueryDetails do - subject { described_class.new(observation) } + subject { described_class.new(observation, directory_path) } let(:observation) { Gitlab::Database::Migrations::Observation.new(migration_version, migration_name) } let(:connection) { ActiveRecord::Base.connection } @@ -14,10 +14,6 @@ RSpec.describe Gitlab::Database::Migrations::Observers::QueryDetails do let(:migration_version) { 20210422152437 } let(:migration_name) { 'test' } - before do - stub_const('Gitlab::Database::Migrations::Instrumentation::RESULT_DIR', directory_path) - end - after do FileUtils.remove_entry(directory_path) end diff --git a/spec/lib/gitlab/database/migrations/observers/query_log_spec.rb b/spec/lib/gitlab/database/migrations/observers/query_log_spec.rb index 2a49d8e8b73..2e70a85fd5b 100644 --- a/spec/lib/gitlab/database/migrations/observers/query_log_spec.rb +++ b/spec/lib/gitlab/database/migrations/observers/query_log_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' RSpec.describe Gitlab::Database::Migrations::Observers::QueryLog do - subject { described_class.new(observation) } + subject { described_class.new(observation, directory_path) } let(:observation) { Gitlab::Database::Migrations::Observation.new(migration_version, migration_name) } let(:connection) { ActiveRecord::Base.connection } @@ -11,10 +11,6 @@ RSpec.describe Gitlab::Database::Migrations::Observers::QueryLog do let(:migration_version) { 20210422152437 } let(:migration_name) { 'test' } - before do - stub_const('Gitlab::Database::Migrations::Instrumentation::RESULT_DIR', directory_path) - end - after do FileUtils.remove_entry(directory_path) end diff --git a/spec/lib/gitlab/database/migrations/observers/query_statistics_spec.rb b/spec/lib/gitlab/database/migrations/observers/query_statistics_spec.rb index 32a25fdaa28..9727a215d71 100644 --- a/spec/lib/gitlab/database/migrations/observers/query_statistics_spec.rb +++ b/spec/lib/gitlab/database/migrations/observers/query_statistics_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' RSpec.describe Gitlab::Database::Migrations::Observers::QueryStatistics do - subject { described_class.new(observation) } + subject { described_class.new(observation, double("unused path")) } let(:observation) { Gitlab::Database::Migrations::Observation.new } let(:connection) { ActiveRecord::Base.connection } diff --git a/spec/lib/gitlab/database/migrations/observers/total_database_size_change_spec.rb b/spec/lib/gitlab/database/migrations/observers/total_database_size_change_spec.rb index 61e28003e66..e689759c574 100644 --- a/spec/lib/gitlab/database/migrations/observers/total_database_size_change_spec.rb +++ b/spec/lib/gitlab/database/migrations/observers/total_database_size_change_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' RSpec.describe Gitlab::Database::Migrations::Observers::TotalDatabaseSizeChange do - subject { described_class.new(observation) } + subject { described_class.new(observation, double('unused path')) } let(:observation) { Gitlab::Database::Migrations::Observation.new } let(:connection) { ActiveRecord::Base.connection } diff --git a/spec/lib/gitlab/database/migrations/runner_spec.rb b/spec/lib/gitlab/database/migrations/runner_spec.rb new file mode 100644 index 00000000000..52fb5ec2ba8 --- /dev/null +++ b/spec/lib/gitlab/database/migrations/runner_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Gitlab::Database::Migrations::Runner do + let(:result_dir) { Pathname.new(Dir.mktmpdir) } + + let(:migration_runs) { [] } # This list gets populated as the runner tries to run migrations + + # Tests depend on all of these lists being sorted in the order migrations would be applied + let(:applied_migrations_other_branches) { [double(ActiveRecord::Migration, version: 1, name: 'migration_complete_other_branch')] } + + let(:applied_migrations_this_branch) do + [ + double(ActiveRecord::Migration, version: 2, name: 'older_migration_complete_this_branch'), + double(ActiveRecord::Migration, version: 3, name: 'newer_migration_complete_this_branch') + ].sort_by(&:version) + end + + let(:pending_migrations) do + [ + double(ActiveRecord::Migration, version: 4, name: 'older_migration_pending'), + double(ActiveRecord::Migration, version: 5, name: 'newer_migration_pending') + ].sort_by(&:version) + end + + before do + stub_const('Gitlab::Database::Migrations::Runner::BASE_RESULT_DIR', result_dir) + allow(ActiveRecord::Migrator).to receive(:new) do |dir, _all_migrations, _schema_migration_class, version_to_migrate| + migrator = double(ActiveRecord::Migrator) + expect(migrator).to receive(:run) do + migration_runs << OpenStruct.new(dir: dir, version_to_migrate: version_to_migrate) + end + migrator + end + + all_versions = (applied_migrations_other_branches + applied_migrations_this_branch).map(&:version) + migrations = applied_migrations_other_branches + applied_migrations_this_branch + pending_migrations + ctx = double(ActiveRecord::MigrationContext, get_all_versions: all_versions, migrations: migrations, schema_migration: ActiveRecord::SchemaMigration) + + allow(described_class).to receive(:migration_context).and_return(ctx) + + names_this_branch = (applied_migrations_this_branch + pending_migrations).map { |m| "db/migrate/#{m.version}_#{m.name}.rb"} + allow(described_class).to receive(:migration_file_names_this_branch).and_return(names_this_branch) + end + + after do + FileUtils.rm_rf(result_dir) + end + + it 'creates the results dir when one does not exist' do + FileUtils.rm_rf(result_dir) + + expect do + described_class.new(direction: :up, migrations: [], result_dir: result_dir).run + end.to change { Dir.exist?(result_dir) }.from(false).to(true) + end + + describe '.up' do + context 'result directory' do + it 'uses the /up subdirectory' do + expect(described_class.up.result_dir).to eq(result_dir.join('up')) + end + end + + context 'migrations to run' do + subject(:up) { described_class.up } + + it 'is the list of pending migrations' do + expect(up.migrations).to eq(pending_migrations) + end + end + + context 'running migrations' do + subject(:up) { described_class.up } + + it 'runs the unapplied migrations in version order', :aggregate_failures do + up.run + + expect(migration_runs.map(&:dir)).to eq([:up, :up]) + expect(migration_runs.map(&:version_to_migrate)).to eq(pending_migrations.map(&:version)) + end + end + end + + describe '.down' do + subject(:down) { described_class.down } + + context 'result directory' do + it 'is the /down subdirectory' do + expect(down.result_dir).to eq(result_dir.join('down')) + end + end + + context 'migrations to run' do + it 'is the list of migrations that are up and on this branch' do + expect(down.migrations).to eq(applied_migrations_this_branch) + end + end + + context 'running migrations' do + it 'runs the applied migrations for the current branch in reverse order', :aggregate_failures do + down.run + + expect(migration_runs.map(&:dir)).to eq([:down, :down]) + expect(migration_runs.map(&:version_to_migrate)).to eq(applied_migrations_this_branch.reverse.map(&:version)) + end + end + end +end diff --git a/spec/lib/gitlab/database/partitioning/detached_partition_dropper_spec.rb b/spec/lib/gitlab/database/partitioning/detached_partition_dropper_spec.rb index 8523b7104f0..8c406c90e36 100644 --- a/spec/lib/gitlab/database/partitioning/detached_partition_dropper_spec.rb +++ b/spec/lib/gitlab/database/partitioning/detached_partition_dropper_spec.rb @@ -84,6 +84,7 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do before do stub_feature_flags(drop_detached_partitions: false) end + it 'does not drop the partition' do subject.perform @@ -162,8 +163,8 @@ RSpec.describe Gitlab::Database::Partitioning::DetachedPartitionDropper do context 'when the first drop returns an error' do it 'still drops the second partition' do - expect(subject).to receive(:drop_one).ordered.and_raise('injected error') - expect(subject).to receive(:drop_one).ordered.and_call_original + expect(subject).to receive(:drop_detached_partition).ordered.and_raise('injected error') + expect(subject).to receive(:drop_detached_partition).ordered.and_call_original subject.perform diff --git a/spec/lib/gitlab/database/partitioning/multi_database_partition_dropper_spec.rb b/spec/lib/gitlab/database/partitioning/multi_database_partition_dropper_spec.rb new file mode 100644 index 00000000000..56d6ebb7aff --- /dev/null +++ b/spec/lib/gitlab/database/partitioning/multi_database_partition_dropper_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::Partitioning::MultiDatabasePartitionDropper, '#drop_detached_partitions' do + subject(:drop_detached_partitions) { multi_db_dropper.drop_detached_partitions } + + let(:multi_db_dropper) { described_class.new } + + let(:connection_wrapper1) { double(scope: scope1) } + let(:connection_wrapper2) { double(scope: scope2) } + + let(:scope1) { double(connection: connection1) } + let(:scope2) { double(connection: connection2) } + + let(:connection1) { double('connection') } + let(:connection2) { double('connection') } + + let(:dropper_class) { Gitlab::Database::Partitioning::DetachedPartitionDropper } + let(:dropper1) { double('partition dropper') } + let(:dropper2) { double('partition dropper') } + + before do + allow(multi_db_dropper).to receive(:databases).and_return({ db1: connection_wrapper1, db2: connection_wrapper2 }) + end + + it 'drops detached partitions for each database' do + expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(connection1).and_yield.ordered + expect(dropper_class).to receive(:new).and_return(dropper1).ordered + expect(dropper1).to receive(:perform) + + expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(connection2).and_yield.ordered + expect(dropper_class).to receive(:new).and_return(dropper2).ordered + expect(dropper2).to receive(:perform) + + drop_detached_partitions + end +end diff --git a/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb b/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb index 8f1f5b5ba1b..7c4cfcfb3a9 100644 --- a/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb +++ b/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb @@ -176,7 +176,7 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do end it 'detaches exactly one partition' do - expect { subject }.to change { find_partitions(my_model.table_name, schema: Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA).size }.from(9).to(8) + expect { subject }.to change { find_partitions(my_model.table_name).size }.from(9).to(8) end it 'detaches the old partition' do diff --git a/spec/lib/gitlab/database/partitioning_spec.rb b/spec/lib/gitlab/database/partitioning_spec.rb index f163b45e01e..486af9413e8 100644 --- a/spec/lib/gitlab/database/partitioning_spec.rb +++ b/spec/lib/gitlab/database/partitioning_spec.rb @@ -33,4 +33,22 @@ RSpec.describe Gitlab::Database::Partitioning do end end end + + describe '.drop_detached_partitions' do + let(:partition_dropper_class) { described_class::MultiDatabasePartitionDropper } + + it 'delegates to the partition dropper' do + expect_next_instance_of(partition_dropper_class) do |partition_dropper| + expect(partition_dropper).to receive(:drop_detached_partitions) + end + + described_class.drop_detached_partitions + end + end + + context 'ensure that the registered models have partitioning strategy' do + it 'fails when partitioning_strategy is not specified for the model' do + expect(described_class.registered_models).to all(respond_to(:partitioning_strategy)) + end + end end diff --git a/spec/lib/gitlab/database/postgresql_adapter/force_disconnectable_mixin_spec.rb b/spec/lib/gitlab/database/postgresql_adapter/force_disconnectable_mixin_spec.rb index 2a1f91b5b21..399fcae2fa0 100644 --- a/spec/lib/gitlab/database/postgresql_adapter/force_disconnectable_mixin_spec.rb +++ b/spec/lib/gitlab/database/postgresql_adapter/force_disconnectable_mixin_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::Database::PostgresqlAdapter::ForceDisconnectableMixin do +RSpec.describe Gitlab::Database::PostgresqlAdapter::ForceDisconnectableMixin, :reestablished_active_record_base do describe 'checking in a connection to the pool' do let(:model) do Class.new(ActiveRecord::Base) do diff --git a/spec/lib/gitlab/database/schema_migrations/context_spec.rb b/spec/lib/gitlab/database/schema_migrations/context_spec.rb index a79e6706149..0323fa22b78 100644 --- a/spec/lib/gitlab/database/schema_migrations/context_spec.rb +++ b/spec/lib/gitlab/database/schema_migrations/context_spec.rb @@ -23,19 +23,7 @@ RSpec.describe Gitlab::Database::SchemaMigrations::Context do end end - context 'multiple databases' do - let(:connection_class) do - Class.new(::ApplicationRecord) do - self.abstract_class = true - - def self.name - 'Gitlab::Database::SchemaMigrations::Context::TestConnection' - end - end - end - - let(:configuration_overrides) { {} } - + context 'multiple databases', :reestablished_active_record_base do before do connection_class.establish_connection( ActiveRecord::Base @@ -46,10 +34,6 @@ RSpec.describe Gitlab::Database::SchemaMigrations::Context do ) end - after do - connection_class.remove_connection - end - context 'when `schema_migrations_path` is configured as string' do let(:configuration_overrides) do { "schema_migrations_path" => "db/ci_schema_migrations" } diff --git a/spec/lib/gitlab/database/with_lock_retries_spec.rb b/spec/lib/gitlab/database/with_lock_retries_spec.rb index 0b960830d89..c2c818aa106 100644 --- a/spec/lib/gitlab/database/with_lock_retries_spec.rb +++ b/spec/lib/gitlab/database/with_lock_retries_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Gitlab::Database::WithLockRetries do let(:logger) { Gitlab::Database::WithLockRetries::NULL_LOGGER } let(:subject) { described_class.new(env: env, logger: logger, allow_savepoints: allow_savepoints, timing_configuration: timing_configuration) } let(:allow_savepoints) { true } - let(:connection) { ActiveRecord::Base.connection } + let(:connection) { ActiveRecord::Base.retrieve_connection } let(:timing_configuration) do [ diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb index a9a8d5e6314..a2e7b6d27b9 100644 --- a/spec/lib/gitlab/database_spec.rb +++ b/spec/lib/gitlab/database_spec.rb @@ -15,6 +15,13 @@ RSpec.describe Gitlab::Database do end end + describe '.databases' do + it 'stores connections as a HashWithIndifferentAccess' do + expect(described_class.databases.has_key?('main')).to be true + expect(described_class.databases.has_key?(:main)).to be true + end + end + describe '.default_pool_size' do before do allow(Gitlab::Runtime).to receive(:max_threads).and_return(7) @@ -185,10 +192,23 @@ RSpec.describe Gitlab::Database do describe '.db_config_name' do it 'returns the db_config name for the connection' do - connection = ActiveRecord::Base.connection + model = ActiveRecord::Base + + # This is a ConnectionProxy + expect(described_class.db_config_name(model.connection)) + .to eq('unknown') - expect(described_class.db_config_name(connection)).to be_a(String) - expect(described_class.db_config_name(connection)).to eq(connection.pool.db_config.name) + # This is an actual connection + expect(described_class.db_config_name(model.retrieve_connection)) + .to eq('main') + end + + context 'when replicas are configured', :database_replica do + it 'returns the name for a replica' do + replica = ActiveRecord::Base.connection.load_balancer.host + + expect(described_class.db_config_name(replica)).to eq('main_replica') + end end end @@ -279,7 +299,7 @@ RSpec.describe Gitlab::Database do 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) + connection: be_a(Gitlab::Database::LoadBalancing::ConnectionProxy) ) end end @@ -296,7 +316,7 @@ RSpec.describe Gitlab::Database do 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) + connection: be_a(Gitlab::Database::LoadBalancing::ConnectionProxy) ) end end @@ -319,7 +339,7 @@ RSpec.describe Gitlab::Database do 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) + connection: be_a(Gitlab::Database::LoadBalancing::ConnectionProxy) ) end end @@ -340,7 +360,7 @@ RSpec.describe Gitlab::Database do 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) + connection: be_a(Gitlab::Database::LoadBalancing::ConnectionProxy) ) end end diff --git a/spec/lib/gitlab/doctor/secrets_spec.rb b/spec/lib/gitlab/doctor/secrets_spec.rb index b9e054ce14f..f95a7eb1492 100644 --- a/spec/lib/gitlab/doctor/secrets_spec.rb +++ b/spec/lib/gitlab/doctor/secrets_spec.rb @@ -5,6 +5,8 @@ require 'spec_helper' RSpec.describe Gitlab::Doctor::Secrets do let!(:user) { create(:user, otp_secret: "test") } let!(:group) { create(:group, runners_token: "test") } + let!(:project) { create(:project) } + let!(:grafana_integration) { create(:grafana_integration, project: project, token: "test") } let(:logger) { double(:logger).as_null_object } subject { described_class.new(logger).run! } @@ -39,4 +41,12 @@ RSpec.describe Gitlab::Doctor::Secrets do subject end end + + context 'when GrafanaIntegration token is set via private method' do + it 'can access GrafanaIntegration token value' do + expect(logger).to receive(:info).with(/GrafanaIntegration failures: 0/) + + subject + end + end end diff --git a/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb b/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb index dd230140b30..bd4f1d164a8 100644 --- a/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb @@ -136,6 +136,36 @@ RSpec.describe Gitlab::Email::Handler::CreateIssueHandler do expect { handler.execute }.to raise_error(Gitlab::Email::ProjectNotFound) end end + + context 'rate limiting' do + let(:rate_limited_service_feature_enabled) { nil } + + before do + stub_feature_flags(rate_limited_service_issues_create: rate_limited_service_feature_enabled) + end + + context 'when :rate_limited_service Feature is disabled' do + let(:rate_limited_service_feature_enabled) { false } + + it 'does not attempt to throttle' do + expect(::Gitlab::ApplicationRateLimiter).not_to receive(:throttled?) + + setup_attachment + receiver.execute + end + end + + context 'when :rate_limited_service Feature is enabled' do + let(:rate_limited_service_feature_enabled) { true } + + it 'raises a RateLimitedService::RateLimitedError' do + allow(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(true) + + setup_attachment + expect { receiver.execute }.to raise_error(RateLimitedService::RateLimitedError, _('This endpoint has been requested too many times. Try again later.')) + end + end + end end def email_fixture(path) 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 2916e65528f..8cb1ccc065b 100644 --- a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb @@ -243,6 +243,15 @@ RSpec.describe Gitlab::Email::Handler::ServiceDeskHandler do end end end + + context 'when rate limiting is in effect' do + it 'allows unlimited new issue creation' do + stub_application_setting(issues_create_limit: 1) + setup_attachment + + expect { 2.times { receiver.execute } }.to change { Issue.count }.by(2) + end + end end describe '#can_handle?' do diff --git a/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb b/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb index 56cf58dcf92..0a1f04ed793 100644 --- a/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb +++ b/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb @@ -14,15 +14,15 @@ RSpec.describe Gitlab::Email::Hook::SmimeSignatureInterceptor do end let(:root_certificate) do - Gitlab::Email::Smime::Certificate.new(@root_ca[:key], @root_ca[:cert]) + Gitlab::X509::Certificate.new(@root_ca[:key], @root_ca[:cert]) end let(:intermediate_certificate) do - Gitlab::Email::Smime::Certificate.new(@intermediate_ca[:key], @intermediate_ca[:cert]) + Gitlab::X509::Certificate.new(@intermediate_ca[:key], @intermediate_ca[:cert]) end let(:certificate) do - Gitlab::Email::Smime::Certificate.new(@cert[:key], @cert[:cert], [intermediate_certificate.cert]) + Gitlab::X509::Certificate.new(@cert[:key], @cert[:cert], [intermediate_certificate.cert]) end let(:mail_body) { "signed hello with Unicode €áø and\r\n newlines\r\n" } @@ -36,7 +36,7 @@ RSpec.describe Gitlab::Email::Hook::SmimeSignatureInterceptor do end before do - allow(Gitlab::Email::Smime::Certificate).to receive_messages(from_files: certificate) + allow(Gitlab::X509::Certificate).to receive_messages(from_files: certificate) Mail.register_interceptor(described_class) mail.deliver_now diff --git a/spec/lib/gitlab/endpoint_attributes_spec.rb b/spec/lib/gitlab/endpoint_attributes_spec.rb new file mode 100644 index 00000000000..4d4cfed57fa --- /dev/null +++ b/spec/lib/gitlab/endpoint_attributes_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require_relative "../../support/matchers/be_request_urgency" +require_relative "../../../lib/gitlab/endpoint_attributes" + +RSpec.describe Gitlab::EndpointAttributes do + let(:base_controller) do + Class.new do + include ::Gitlab::EndpointAttributes + end + end + + let(:controller) do + Class.new(base_controller) do + feature_category :foo, %w(update edit) + feature_category :bar, %w(index show) + feature_category :quux, %w(destroy) + + urgency :high, %w(do_a) + urgency :low, %w(do_b do_c) + end + end + + let(:subclass) do + Class.new(controller) do + feature_category :baz, %w(subclass_index) + urgency :high, %w(superclass_do_something) + end + end + + it "is nil when nothing was defined" do + expect(base_controller.feature_category_for_action("hello")).to be_nil + end + + it "returns the expected category", :aggregate_failures do + expect(controller.feature_category_for_action("update")).to eq(:foo) + expect(controller.feature_category_for_action("index")).to eq(:bar) + expect(controller.feature_category_for_action("destroy")).to eq(:quux) + end + + it "falls back to default when urgency was not defined", :aggregate_failures do + expect(base_controller.urgency_for_action("hello")).to be_request_urgency(:default) + expect(controller.urgency_for_action("update")).to be_request_urgency(:default) + expect(controller.urgency_for_action("index")).to be_request_urgency(:default) + expect(controller.urgency_for_action("destroy")).to be_request_urgency(:default) + end + + it "returns the expected urgency", :aggregate_failures do + expect(controller.urgency_for_action("do_a")).to be_request_urgency(:high) + expect(controller.urgency_for_action("do_b")).to be_request_urgency(:low) + expect(controller.urgency_for_action("do_c")).to be_request_urgency(:low) + end + + it "returns feature category for an implied action if not specify actions" do + klass = Class.new(base_controller) do + feature_category :foo + end + expect(klass.feature_category_for_action("index")).to eq(:foo) + expect(klass.feature_category_for_action("show")).to eq(:foo) + end + + it "returns expected duration for an implied action if not specify actions" do + klass = Class.new(base_controller) do + feature_category :foo + urgency :low + end + expect(klass.urgency_for_action("index")).to be_request_urgency(:low) + expect(klass.urgency_for_action("show")).to be_request_urgency(:low) + end + + it "returns the expected category for categories defined in subclasses" do + expect(subclass.feature_category_for_action("subclass_index")).to eq(:baz) + end + + it "falls back to superclass's feature category" do + expect(subclass.feature_category_for_action("update")).to eq(:foo) + end + + it "returns the expected urgency for categories defined in subclasses" do + expect(subclass.urgency_for_action("superclass_do_something")).to be_request_urgency(:high) + end + + it "falls back to superclass's expected duration" do + expect(subclass.urgency_for_action("do_a")).to be_request_urgency(:high) + end + + it "raises an error when defining for the controller and for individual actions" do + expect do + Class.new(base_controller) do + feature_category :hello + feature_category :goodbye, [:world] + end + end.to raise_error(ArgumentError, "feature_category are already defined for all actions, but re-defined for world") + end + + it "raises an error when multiple calls define the same action" do + expect do + Class.new(base_controller) do + feature_category :hello, [:world] + feature_category :goodbye, ["world"] + end + end.to raise_error(ArgumentError, "Attributes re-defined for action world: feature_category") + end + + it "raises an error when multiple calls define the same action" do + expect do + Class.new(base_controller) do + urgency :high, [:world] + urgency :low, ["world"] + end + end.to raise_error(ArgumentError, "Attributes re-defined for action world: urgency") + end + + it "does not raise an error when multiple calls define the same action and configs" do + expect do + Class.new(base_controller) do + feature_category :hello, [:world] + feature_category :hello, ["world"] + urgency :medium, [:moon] + urgency :medium, ["moon"] + end + end.not_to raise_error + end + + it "raises an error if the expected duration is not supported" do + expect do + Class.new(base_controller) do + urgency :super_slow + end + end.to raise_error(ArgumentError, "Urgency not supported: super_slow") + end +end diff --git a/spec/lib/gitlab/etag_caching/router/graphql_spec.rb b/spec/lib/gitlab/etag_caching/router/graphql_spec.rb index d151dcba413..9a6787e3640 100644 --- a/spec/lib/gitlab/etag_caching/router/graphql_spec.rb +++ b/spec/lib/gitlab/etag_caching/router/graphql_spec.rb @@ -11,7 +11,7 @@ RSpec.describe Gitlab::EtagCaching::Router::Graphql do 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 + feature_categories = Gitlab::FeatureCategories.default.categories 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" diff --git a/spec/lib/gitlab/etag_caching/router/restful_spec.rb b/spec/lib/gitlab/etag_caching/router/restful_spec.rb index 1f5cac09b6d..a0fc480369c 100644 --- a/spec/lib/gitlab/etag_caching/router/restful_spec.rb +++ b/spec/lib/gitlab/etag_caching/router/restful_spec.rb @@ -107,7 +107,7 @@ RSpec.describe Gitlab::EtagCaching::Router::Restful do 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 + feature_categories = Gitlab::FeatureCategories.default.categories 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" diff --git a/spec/lib/gitlab/feature_categories_spec.rb b/spec/lib/gitlab/feature_categories_spec.rb new file mode 100644 index 00000000000..daced154a69 --- /dev/null +++ b/spec/lib/gitlab/feature_categories_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::FeatureCategories do + let(:fake_categories) { %w(foo bar) } + + subject { described_class.new(fake_categories) } + + describe "#valid?" do + it "returns true if category is known", :aggregate_failures do + expect(subject.valid?('foo')).to be(true) + expect(subject.valid?('zzz')).to be(false) + end + end + + describe "#from_request" do + let(:request_env) { {} } + let(:verified) { true } + + def fake_request(request_feature_category) + double('request', env: request_env, headers: { "HTTP_X_GITLAB_FEATURE_CATEGORY" => request_feature_category }) + end + + before do + allow(::Gitlab::RequestForgeryProtection).to receive(:verified?).with(request_env).and_return(verified) + end + + it "returns category from request when valid, otherwise returns nil", :aggregate_failures do + expect(subject.from_request(fake_request("foo"))).to be("foo") + expect(subject.from_request(fake_request("zzz"))).to be_nil + end + + context "when request is not verified" do + let(:verified) { false } + + it "returns nil" do + expect(subject.from_request(fake_request("foo"))).to be_nil + end + end + end + + describe "#categories" do + it "returns a set of the given categories" do + expect(subject.categories).to be_a(Set) + expect(subject.categories).to contain_exactly(*fake_categories) + end + end + + describe ".load_from_yaml" do + subject { described_class.load_from_yaml } + + it "creates FeatureCategories from feature_categories.yml file" do + contents = YAML.load_file(Rails.root.join('config', 'feature_categories.yml')) + + expect(subject.categories).to contain_exactly(*contents) + end + end + + describe ".default" do + it "returns a memoization of load_from_yaml", :aggregate_failures do + # FeatureCategories.default could have been referenced in another spec, so we need to clean it up here + described_class.instance_variable_set(:@default, nil) + + expect(described_class).to receive(:load_from_yaml).once.and_call_original + + 2.times { described_class.default } + + # Uses reference equality to verify memoization + expect(described_class.default).to equal(described_class.default) + expect(described_class.default).to be_a(described_class) + end + end +end diff --git a/spec/lib/gitlab/form_builders/gitlab_ui_form_builder_spec.rb b/spec/lib/gitlab/form_builders/gitlab_ui_form_builder_spec.rb index a46846e9820..e160e88487b 100644 --- a/spec/lib/gitlab/form_builders/gitlab_ui_form_builder_spec.rb +++ b/spec/lib/gitlab/form_builders/gitlab_ui_form_builder_spec.rb @@ -75,7 +75,68 @@ RSpec.describe Gitlab::FormBuilders::GitlabUiFormBuilder do checkbox_html - expect(fake_template).to have_received(:label).with(:user, :view_diffs_file_by_file, { class: %w(custom-control-label label-foo-bar), object: user }) + expect(fake_template).to have_received(:label).with(:user, :view_diffs_file_by_file, { class: %w(custom-control-label label-foo-bar), object: user, value: nil }) + end + end + end + + describe '#gitlab_ui_radio_component' do + let(:optional_args) { {} } + + subject(:radio_html) { form_builder.gitlab_ui_radio_component(:access_level, :admin, "Access Level", **optional_args) } + + context 'without optional arguments' do + it 'renders correct html' do + expected_html = <<~EOS + <div class="gl-form-radio custom-control custom-radio"> + <input class="custom-control-input" type="radio" value="admin" name="user[access_level]" id="user_access_level_admin" /> + <label class="custom-control-label" for="user_access_level_admin"> + Access Level + </label> + </div> + EOS + + expect(radio_html).to eq(html_strip_whitespace(expected_html)) + end + end + + context 'with optional arguments' do + let(:optional_args) do + { + help_text: 'Administrators have access to all groups, projects, and users and can manage all features in this installation', + radio_options: { class: 'radio-foo-bar' }, + label_options: { class: 'label-foo-bar' } + } + end + + it 'renders help text' do + expected_html = <<~EOS + <div class="gl-form-radio custom-control custom-radio"> + <input class="custom-control-input radio-foo-bar" type="radio" value="admin" name="user[access_level]" id="user_access_level_admin" /> + <label class="custom-control-label label-foo-bar" for="user_access_level_admin"> + <span>Access Level</span> + <p class="help-text">Administrators have access to all groups, projects, and users and can manage all features in this installation</p> + </label> + </div> + EOS + + expect(radio_html).to eq(html_strip_whitespace(expected_html)) + end + + it 'passes arguments to `radio_button` method' do + allow(fake_template).to receive(:radio_button).and_return('') + + radio_html + + expect(fake_template).to have_received(:radio_button).with(:user, :access_level, :admin, { class: %w(custom-control-input radio-foo-bar), object: user }) + end + + it 'passes arguments to `label` method' do + allow(fake_template).to receive(:label).and_return('') + + radio_html + + expect(fake_template).to have_received(:label).with(:user, :access_level, { class: %w(custom-control-label label-foo-bar), object: user, value: :admin }) end end end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 9ecd281cce0..c7b68ff3e28 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -2238,7 +2238,6 @@ RSpec.describe Gitlab::Git::Repository, :seed_helper do end describe '#squash' do - let(:squash_id) { '1' } let(:branch_name) { 'fix' } let(:start_sha) { '4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6' } let(:end_sha) { '12d65c8dd2b2676fa3ac47d955accc085a37a9c1' } @@ -2252,7 +2251,7 @@ RSpec.describe Gitlab::Git::Repository, :seed_helper do message: 'Squash commit message' } - repository.squash(user, squash_id, opts) + repository.squash(user, opts) end # Should be ported to gitaly-ruby rspec suite https://gitlab.com/gitlab-org/gitaly/issues/1234 diff --git a/spec/lib/gitlab/git/wraps_gitaly_errors_spec.rb b/spec/lib/gitlab/git/wraps_gitaly_errors_spec.rb index 2c9da0f6606..e551dfaa1c5 100644 --- a/spec/lib/gitlab/git/wraps_gitaly_errors_spec.rb +++ b/spec/lib/gitlab/git/wraps_gitaly_errors_spec.rb @@ -12,6 +12,7 @@ RSpec.describe Gitlab::Git::WrapsGitalyErrors do mapping = { GRPC::NotFound => Gitlab::Git::Repository::NoRepository, GRPC::InvalidArgument => ArgumentError, + GRPC::DeadlineExceeded => Gitlab::Git::CommandTimedOut, GRPC::BadStatus => Gitlab::Git::CommandError } diff --git a/spec/lib/gitlab/gitaly_client/operation_service_spec.rb b/spec/lib/gitlab/gitaly_client/operation_service_spec.rb index 3789bc76a94..27e7d446770 100644 --- a/spec/lib/gitlab/gitaly_client/operation_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/operation_service_spec.rb @@ -169,6 +169,56 @@ RSpec.describe Gitlab::GitalyClient::OperationService do end end + describe '#user_merge_branch' do + let(:target_branch) { 'master' } + let(:source_sha) { '5937ac0a7beb003549fc5fd26fc247adbce4a52e' } + let(:message) { 'Merge a branch' } + + subject { client.user_merge_branch(user, source_sha, target_branch, message) {} } + + it 'sends a user_merge_branch message' do + expect(subject).to be_a(Gitlab::Git::OperationService::BranchUpdate) + expect(subject.newrev).to be_present + expect(subject.repo_created).to be(false) + expect(subject.branch_created).to be(false) + end + + context 'with an exception with the UserMergeBranchError' do + let(:permission_error) do + GRPC::PermissionDenied.new( + "GitLab: You are not allowed to push code to this project.", + { "grpc-status-details-bin" => + "\b\a\x129GitLab: You are not allowed to push code to this project.\x1A\xDE\x01\n/type.googleapis.com/gitaly.UserMergeBranchError\x12\xAA\x01\n\xA7\x01\n1You are not allowed to push code to this project.\x12\x03web\x1A\auser-15\"df15b32277d2c55c6c595845a87109b09c913c556 5d6e0f935ad9240655f64e883cd98fad6f9a17ee refs/heads/master\n" } + ) + end + + it 'raises PreRecieveError with the error message' do + expect_any_instance_of(Gitaly::OperationService::Stub) + .to receive(:user_merge_branch).with(kind_of(Enumerator), kind_of(Hash)) + .and_raise(permission_error) + + expect { subject }.to raise_error do |error| + expect(error).to be_a(Gitlab::Git::PreReceiveError) + expect(error.message).to eq("You are not allowed to push code to this project.") + end + end + end + + context 'with an exception without the detailed error' do + let(:permission_error) do + GRPC::PermissionDenied.new + end + + it 'raises PermissionDenied' do + expect_any_instance_of(Gitaly::OperationService::Stub) + .to receive(:user_merge_branch).with(kind_of(Enumerator), kind_of(Hash)) + .and_raise(permission_error) + + expect { subject }.to raise_error(GRPC::PermissionDenied) + end + end + end + describe '#user_ff_branch' do let(:target_branch) { 'my-branch' } let(:source_sha) { 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660' } @@ -308,7 +358,6 @@ RSpec.describe Gitlab::GitalyClient::OperationService do end describe '#user_squash' do - let(:squash_id) { '1' } let(:start_sha) { 'b83d6e391c22777fca1ed3012fce84f633d7fed0' } let(:end_sha) { '54cec5282aa9f21856362fe321c800c236a61615' } let(:commit_message) { 'Squash message' } @@ -321,7 +370,6 @@ RSpec.describe Gitlab::GitalyClient::OperationService do Gitaly::UserSquashRequest.new( repository: repository.gitaly_repository, user: gitaly_user, - squash_id: squash_id.to_s, start_sha: start_sha, end_sha: end_sha, author: gitaly_user, @@ -334,7 +382,7 @@ RSpec.describe Gitlab::GitalyClient::OperationService do let(:response) { Gitaly::UserSquashResponse.new(squash_sha: squash_sha) } subject do - client.user_squash(user, squash_id, start_sha, end_sha, user, commit_message, time) + client.user_squash(user, start_sha, end_sha, user, commit_message, time) end it 'sends a user_squash message and returns the squash sha' do diff --git a/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb index 46b9959ff64..be4fc3cbf16 100644 --- a/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb @@ -15,10 +15,18 @@ RSpec.describe Gitlab::GithubImport::Importer::DiffNotesImporter do original_commit_id: 'original123abc', diff_hunk: "@@ -1 +1 @@\n-Hello\n+Hello world", user: double(:user, id: 4, login: 'alice'), - body: 'Hello world', created_at: Time.zone.now, updated_at: Time.zone.now, - id: 1 + line: 23, + start_line: nil, + id: 1, + body: <<~BODY + Hello World + + ```suggestion + sug1 + ``` + BODY ) end diff --git a/spec/lib/gitlab/github_import/parallel_importer_spec.rb b/spec/lib/gitlab/github_import/parallel_importer_spec.rb index 06304bf84ca..c7b300ff043 100644 --- a/spec/lib/gitlab/github_import/parallel_importer_spec.rb +++ b/spec/lib/gitlab/github_import/parallel_importer_spec.rb @@ -9,6 +9,18 @@ RSpec.describe Gitlab::GithubImport::ParallelImporter do end end + describe '.track_start_import' do + it 'tracks the start of import' do + project = double(:project) + metrics = double(:metrics) + + expect(Gitlab::Import::Metrics).to receive(:new).with(:github_importer, project).and_return(metrics) + expect(metrics).to receive(:track_start_import) + + described_class.track_start_import(project) + end + end + describe '#execute', :clean_gitlab_redis_shared_state do let(:project) { create(:project) } let(:importer) { described_class.new(project) } diff --git a/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb b/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb index 1fc7d3c887f..f375e84e0fd 100644 --- a/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb +++ b/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb @@ -130,7 +130,8 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do project_id: project.id, exception: exception, error_source: 'MyImporter', - fail_import: false + fail_import: false, + metrics: true ).and_call_original expect { importer.execute } @@ -195,7 +196,8 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do project_id: project.id, exception: exception, error_source: 'MyImporter', - fail_import: true + fail_import: true, + metrics: true ).and_call_original expect { importer.execute } diff --git a/spec/lib/gitlab/github_import/representation/diff_note_spec.rb b/spec/lib/gitlab/github_import/representation/diff_note_spec.rb index 7c24cd0a5db..81722c0eba7 100644 --- a/spec/lib/gitlab/github_import/representation/diff_note_spec.rb +++ b/spec/lib/gitlab/github_import/representation/diff_note_spec.rb @@ -51,7 +51,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do end it 'includes the GitHub ID' do - expect(note.github_id).to eq(1) + expect(note.note_id).to eq(1) end it 'returns the noteable type' do @@ -73,6 +73,8 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do body: 'Hello world', created_at: created_at, updated_at: updated_at, + line: 23, + start_line: nil, id: 1 ) end @@ -90,47 +92,70 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do expect(note.author).to be_nil end - end - describe '.from_json_hash' do - it_behaves_like 'a DiffNote' do - let(:hash) do - { - 'noteable_type' => 'MergeRequest', - 'noteable_id' => 42, - 'file_path' => 'README.md', - 'commit_id' => '123abc', - 'original_commit_id' => 'original123abc', - 'diff_hunk' => hunk, - 'author' => { 'id' => 4, 'login' => 'alice' }, - 'note' => 'Hello world', - 'created_at' => created_at.to_s, - 'updated_at' => updated_at.to_s, - 'github_id' => 1 - } - end + it 'formats a suggestion in the note body' do + allow(response) + .to receive(:body) + .and_return <<~BODY + ```suggestion + Hello World + ``` + BODY - let(:note) { described_class.from_json_hash(hash) } + note = described_class.from_api_response(response) + + expect(note.note).to eq <<~BODY + ```suggestion:-0+0 + Hello World + ``` + BODY end + end - it 'does not convert the author if it was not specified' do - hash = { + describe '.from_json_hash' do + let(:hash) do + { 'noteable_type' => 'MergeRequest', 'noteable_id' => 42, 'file_path' => 'README.md', 'commit_id' => '123abc', 'original_commit_id' => 'original123abc', 'diff_hunk' => hunk, + 'author' => { 'id' => 4, 'login' => 'alice' }, 'note' => 'Hello world', 'created_at' => created_at.to_s, 'updated_at' => updated_at.to_s, - 'github_id' => 1 + 'note_id' => 1 } + end + + it_behaves_like 'a DiffNote' do + let(:note) { described_class.from_json_hash(hash) } + end + + it 'does not convert the author if it was not specified' do + hash.delete('author') note = described_class.from_json_hash(hash) expect(note.author).to be_nil end + + it 'formats a suggestion in the note body' do + hash['note'] = <<~BODY + ```suggestion + Hello World + ``` + BODY + + note = described_class.from_json_hash(hash) + + expect(note.note).to eq <<~BODY + ```suggestion:-0+0 + Hello World + ``` + BODY + end end describe '#line_code' do @@ -154,7 +179,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do 'note' => 'Hello world', 'created_at' => created_at.to_s, 'updated_at' => updated_at.to_s, - 'github_id' => 1 + 'note_id' => 1 ) expect(note.diff_hash).to eq( @@ -167,4 +192,68 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do ) end end + + describe '#github_identifiers' do + it 'returns a hash with needed identifiers' do + github_identifiers = { + noteable_id: 42, + noteable_type: 'MergeRequest', + note_id: 1 + } + other_attributes = { something_else: '_something_else_' } + note = described_class.new(github_identifiers.merge(other_attributes)) + + expect(note.github_identifiers).to eq(github_identifiers) + end + end + + describe '#note' do + it 'returns the given note' do + hash = { + 'note': 'simple text' + } + + note = described_class.new(hash) + + expect(note.note).to eq 'simple text' + end + + it 'returns the suggestion formatted in the note' do + hash = { + 'note': <<~BODY + ```suggestion + Hello World + ``` + BODY + } + + note = described_class.new(hash) + + expect(note.note).to eq <<~BODY + ```suggestion:-0+0 + Hello World + ``` + BODY + end + + it 'returns the multi-line suggestion formatted in the note' do + hash = { + 'start_line': 20, + 'end_line': 23, + 'note': <<~BODY + ```suggestion + Hello World + ``` + BODY + } + + note = described_class.new(hash) + + expect(note.note).to eq <<~BODY + ```suggestion:-3+0 + Hello World + ``` + BODY + end + end end diff --git a/spec/lib/gitlab/github_import/representation/diff_notes/suggestion_formatter_spec.rb b/spec/lib/gitlab/github_import/representation/diff_notes/suggestion_formatter_spec.rb new file mode 100644 index 00000000000..2ffd5f50d3b --- /dev/null +++ b/spec/lib/gitlab/github_import/representation/diff_notes/suggestion_formatter_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormatter do + it 'does nothing when there is any text before the suggestion tag' do + note = <<~BODY + looks like```suggestion but it isn't + ``` + BODY + + expect(described_class.formatted_note_for(note: note)).to eq(note) + end + + it 'handles nil value for note' do + note = nil + + expect(described_class.formatted_note_for(note: note)).to eq(note) + end + + it 'does not allow over 3 leading spaces for valid suggestion' do + note = <<~BODY + Single-line suggestion + ```suggestion + sug1 + ``` + BODY + + expect(described_class.formatted_note_for(note: note)).to eq(note) + end + + it 'allows up to 3 leading spaces' do + note = <<~BODY + Single-line suggestion + ```suggestion + sug1 + ``` + BODY + + expected = <<~BODY + Single-line suggestion + ```suggestion:-0+0 + sug1 + ``` + BODY + + expect(described_class.formatted_note_for(note: note)).to eq(expected) + end + + it 'does nothing when there is any text without space after the suggestion tag' do + note = <<~BODY + ```suggestionbut it isn't + ``` + BODY + + expect(described_class.formatted_note_for(note: note)).to eq(note) + end + + it 'formats single-line suggestions' do + note = <<~BODY + Single-line suggestion + ```suggestion + sug1 + ``` + BODY + + expected = <<~BODY + Single-line suggestion + ```suggestion:-0+0 + sug1 + ``` + BODY + + expect(described_class.formatted_note_for(note: note)).to eq(expected) + end + + it 'ignores text after suggestion tag on the same line' do + note = <<~BODY + looks like + ```suggestion text to be ignored + suggestion + ``` + BODY + + expected = <<~BODY + looks like + ```suggestion:-0+0 + suggestion + ``` + BODY + + expect(described_class.formatted_note_for(note: note)).to eq(expected) + end + + it 'formats multiple single-line suggestions' do + note = <<~BODY + Single-line suggestion + ```suggestion + sug1 + ``` + OR + ```suggestion + sug2 + ``` + BODY + + expected = <<~BODY + Single-line suggestion + ```suggestion:-0+0 + sug1 + ``` + OR + ```suggestion:-0+0 + sug2 + ``` + BODY + + expect(described_class.formatted_note_for(note: note)).to eq(expected) + end + + it 'formats multi-line suggestions' do + note = <<~BODY + Multi-line suggestion + ```suggestion + sug1 + ``` + BODY + + expected = <<~BODY + Multi-line suggestion + ```suggestion:-2+0 + sug1 + ``` + BODY + + expect(described_class.formatted_note_for(note: note, start_line: 6, end_line: 8)).to eq(expected) + end + + it 'formats multiple multi-line suggestions' do + note = <<~BODY + Multi-line suggestion + ```suggestion + sug1 + ``` + OR + ```suggestion + sug2 + ``` + BODY + + expected = <<~BODY + Multi-line suggestion + ```suggestion:-2+0 + sug1 + ``` + OR + ```suggestion:-2+0 + sug2 + ``` + BODY + + expect(described_class.formatted_note_for(note: note, start_line: 6, end_line: 8)).to eq(expected) + end +end diff --git a/spec/lib/gitlab/github_import/representation/issue_spec.rb b/spec/lib/gitlab/github_import/representation/issue_spec.rb index 3d306a4a3a3..f3052efea70 100644 --- a/spec/lib/gitlab/github_import/representation/issue_spec.rb +++ b/spec/lib/gitlab/github_import/representation/issue_spec.rb @@ -181,4 +181,17 @@ RSpec.describe Gitlab::GithubImport::Representation::Issue do expect(object.truncated_title).to eq('foo') end end + + describe '#github_identifiers' do + it 'returns a hash with needed identifiers' do + github_identifiers = { + iid: 42, + issuable_type: 'MergeRequest' + } + other_attributes = { pull_request: true, something_else: '_something_else_' } + issue = described_class.new(github_identifiers.merge(other_attributes)) + + expect(issue.github_identifiers).to eq(github_identifiers) + end + end end diff --git a/spec/lib/gitlab/github_import/representation/lfs_object_spec.rb b/spec/lib/gitlab/github_import/representation/lfs_object_spec.rb new file mode 100644 index 00000000000..b59ea513436 --- /dev/null +++ b/spec/lib/gitlab/github_import/representation/lfs_object_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::GithubImport::Representation::LfsObject do + describe '#github_identifiers' do + it 'returns a hash with needed identifiers' do + github_identifiers = { + oid: 42 + } + other_attributes = { something_else: '_something_else_' } + lfs_object = described_class.new(github_identifiers.merge(other_attributes)) + + expect(lfs_object.github_identifiers).to eq(github_identifiers) + end + end +end diff --git a/spec/lib/gitlab/github_import/representation/note_spec.rb b/spec/lib/gitlab/github_import/representation/note_spec.rb index 112bb7eb908..97addcc1c98 100644 --- a/spec/lib/gitlab/github_import/representation/note_spec.rb +++ b/spec/lib/gitlab/github_import/representation/note_spec.rb @@ -40,8 +40,8 @@ RSpec.describe Gitlab::GithubImport::Representation::Note do expect(note.updated_at).to eq(updated_at) end - it 'includes the GitHub ID' do - expect(note.github_id).to eq(1) + it 'includes the note ID' do + expect(note.note_id).to eq(1) end end end @@ -84,7 +84,7 @@ RSpec.describe Gitlab::GithubImport::Representation::Note do 'note' => 'Hello world', 'created_at' => created_at.to_s, 'updated_at' => updated_at.to_s, - 'github_id' => 1 + 'note_id' => 1 } end @@ -98,7 +98,7 @@ RSpec.describe Gitlab::GithubImport::Representation::Note do 'note' => 'Hello world', 'created_at' => created_at.to_s, 'updated_at' => updated_at.to_s, - 'github_id' => 1 + 'note_id' => 1 } note = described_class.from_json_hash(hash) @@ -106,4 +106,18 @@ RSpec.describe Gitlab::GithubImport::Representation::Note do expect(note.author).to be_nil end end + + describe '#github_identifiers' do + it 'returns a hash with needed identifiers' do + github_identifiers = { + noteable_id: 42, + noteable_type: 'Issue', + note_id: 1 + } + other_attributes = { something_else: '_something_else_' } + note = described_class.new(github_identifiers.merge(other_attributes)) + + expect(note.github_identifiers).to eq(github_identifiers) + end + end end diff --git a/spec/lib/gitlab/github_import/representation/pull_request_review_spec.rb b/spec/lib/gitlab/github_import/representation/pull_request_review_spec.rb index cad9b13774e..f812fd85fbc 100644 --- a/spec/lib/gitlab/github_import/representation/pull_request_review_spec.rb +++ b/spec/lib/gitlab/github_import/representation/pull_request_review_spec.rb @@ -14,7 +14,7 @@ RSpec.describe Gitlab::GithubImport::Representation::PullRequestReview do expect(review.note).to eq('note') expect(review.review_type).to eq('APPROVED') expect(review.submitted_at).to eq(submitted_at) - expect(review.github_id).to eq(999) + expect(review.review_id).to eq(999) expect(review.merge_request_id).to eq(42) end end @@ -50,7 +50,7 @@ RSpec.describe Gitlab::GithubImport::Representation::PullRequestReview do describe '.from_json_hash' do let(:hash) do { - 'github_id' => 999, + 'review_id' => 999, 'merge_request_id' => 42, 'note' => 'note', 'review_type' => 'APPROVED', @@ -75,4 +75,17 @@ RSpec.describe Gitlab::GithubImport::Representation::PullRequestReview do expect(review.submitted_at).to be_nil end end + + describe '#github_identifiers' do + it 'returns a hash with needed identifiers' do + github_identifiers = { + review_id: 999, + merge_request_id: 42 + } + other_attributes = { something_else: '_something_else_' } + review = described_class.new(github_identifiers.merge(other_attributes)) + + expect(review.github_identifiers).to eq(github_identifiers) + end + end end diff --git a/spec/lib/gitlab/github_import/representation/pull_request_spec.rb b/spec/lib/gitlab/github_import/representation/pull_request_spec.rb index 27a82951b01..925dba5b5a7 100644 --- a/spec/lib/gitlab/github_import/representation/pull_request_spec.rb +++ b/spec/lib/gitlab/github_import/representation/pull_request_spec.rb @@ -288,4 +288,16 @@ RSpec.describe Gitlab::GithubImport::Representation::PullRequest do expect(object.truncated_title).to eq('foo') end end + + describe '#github_identifiers' do + it 'returns a hash with needed identifiers' do + github_identifiers = { + iid: 1 + } + other_attributes = { something_else: '_something_else_' } + pr = described_class.new(github_identifiers.merge(other_attributes)) + + expect(pr.github_identifiers).to eq(github_identifiers.merge(issuable_type: 'MergeRequest')) + end + end end diff --git a/spec/lib/gitlab/github_import/sequential_importer_spec.rb b/spec/lib/gitlab/github_import/sequential_importer_spec.rb index 3c3f8ff59d0..2b76f0e27c9 100644 --- a/spec/lib/gitlab/github_import/sequential_importer_spec.rb +++ b/spec/lib/gitlab/github_import/sequential_importer_spec.rb @@ -4,10 +4,17 @@ require 'spec_helper' RSpec.describe Gitlab::GithubImport::SequentialImporter do describe '#execute' do + let_it_be(:project) do + create(:project, import_url: 'http://t0ken@github.another-domain.com/repo-org/repo.git', import_type: 'github') + end + + subject(:importer) { described_class.new(project, token: 'foo') } + it 'imports a project in sequence' do - repository = double(:repository) - project = double(:project, id: 1, repository: repository, import_url: 'http://t0ken@github.another-domain.com/repo-org/repo.git', group: nil) - importer = described_class.new(project, token: 'foo') + expect_next_instance_of(Gitlab::Import::Metrics) do |instance| + expect(instance).to receive(:track_start_import) + expect(instance).to receive(:track_finished_import) + end expect_next_instance_of(Gitlab::GithubImport::Importer::RepositoryImporter) do |instance| expect(instance).to receive(:execute) @@ -35,5 +42,23 @@ RSpec.describe Gitlab::GithubImport::SequentialImporter do expect(importer.execute).to eq(true) end + + it 'raises an error' do + exception = StandardError.new('_some_error_') + + expect_next_instance_of(Gitlab::GithubImport::Importer::RepositoryImporter) do |importer| + expect(importer).to receive(:execute).and_raise(exception) + end + expect(Gitlab::Import::ImportFailureService).to receive(:track) + .with( + project_id: project.id, + exception: exception, + error_source: described_class.name, + fail_import: true, + metrics: true + ).and_call_original + + expect { importer.execute }.to raise_error(StandardError) + end end end diff --git a/spec/lib/gitlab/health_checks/probes/collection_spec.rb b/spec/lib/gitlab/health_checks/probes/collection_spec.rb index 69828c143db..741c45d953c 100644 --- a/spec/lib/gitlab/health_checks/probes/collection_spec.rb +++ b/spec/lib/gitlab/health_checks/probes/collection_spec.rb @@ -16,6 +16,9 @@ RSpec.describe Gitlab::HealthChecks::Probes::Collection do Gitlab::HealthChecks::Redis::CacheCheck, Gitlab::HealthChecks::Redis::QueuesCheck, Gitlab::HealthChecks::Redis::SharedStateCheck, + Gitlab::HealthChecks::Redis::TraceChunksCheck, + Gitlab::HealthChecks::Redis::RateLimitingCheck, + Gitlab::HealthChecks::Redis::SessionsCheck, Gitlab::HealthChecks::GitalyCheck ] end diff --git a/spec/lib/gitlab/health_checks/redis/rate_limiting_check_spec.rb b/spec/lib/gitlab/health_checks/redis/rate_limiting_check_spec.rb new file mode 100644 index 00000000000..1521fc99cde --- /dev/null +++ b/spec/lib/gitlab/health_checks/redis/rate_limiting_check_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../simple_check_shared' + +RSpec.describe Gitlab::HealthChecks::Redis::RateLimitingCheck do + include_examples 'simple_check', 'redis_rate_limiting_ping', 'RedisRateLimiting', 'PONG' +end diff --git a/spec/lib/gitlab/health_checks/redis/sessions_check_spec.rb b/spec/lib/gitlab/health_checks/redis/sessions_check_spec.rb new file mode 100644 index 00000000000..82b3b33ec0a --- /dev/null +++ b/spec/lib/gitlab/health_checks/redis/sessions_check_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../simple_check_shared' + +RSpec.describe Gitlab::HealthChecks::Redis::SessionsCheck do + include_examples 'simple_check', 'redis_sessions_ping', 'RedisSessions', 'PONG' +end diff --git a/spec/lib/gitlab/import/import_failure_service_spec.rb b/spec/lib/gitlab/import/import_failure_service_spec.rb index 50b32d634ad..c16d4a7c804 100644 --- a/spec/lib/gitlab/import/import_failure_service_spec.rb +++ b/spec/lib/gitlab/import/import_failure_service_spec.rb @@ -2,135 +2,171 @@ require 'spec_helper' -RSpec.describe Gitlab::Import::ImportFailureService do +RSpec.describe Gitlab::Import::ImportFailureService, :aggregate_failures do let_it_be(:import_type) { 'import_type' } + let_it_be(:project) { create(:project, :import_started, import_type: import_type) } - let_it_be(:project) do - create( - :project, - :import_started, - import_type: import_type - ) - end - - let(:import_state) { project.import_state } let(:exception) { StandardError.new('some error') } + let(:arguments) { { project_id: project.id } } + let(:base_arguments) { { error_source: 'SomeImporter', exception: exception }.merge(arguments) } + let(:exe_arguments) { { fail_import: false, metrics: false } } + + describe '.track' do + context 'with all arguments provided' do + let(:instance) { double(:failure_service) } + let(:instance_arguments) do + { + exception: exception, + import_state: '_import_state_', + project_id: '_project_id_', + error_source: '_error_source_' + } + end - shared_examples 'logs the exception and fails the import' do - it 'when the failure does not abort the import' do - expect(Gitlab::ErrorTracking) - .to receive(:track_exception) - .with( - exception, - project_id: project.id, - import_type: import_type, - source: 'SomeImporter' - ) - - expect(Gitlab::Import::Logger) - .to receive(:error) - .with( - message: 'importer failed', - 'error.message': 'some error', - project_id: project.id, - import_type: import_type, - source: 'SomeImporter' - ) - - described_class.track(**arguments) - - expect(project.import_state.reload.status).to eq('failed') - - expect(project.import_failures).not_to be_empty - expect(project.import_failures.last.exception_class).to eq('StandardError') - expect(project.import_failures.last.exception_message).to eq('some error') - end - end + let(:exe_arguments) do + { + fail_import: '_fail_import_', + metrics: '_metrics_' + } + end - shared_examples 'logs the exception and does not fail the import' do - it 'when the failure does not abort the import' do - expect(Gitlab::ErrorTracking) - .to receive(:track_exception) - .with( - exception, - project_id: project.id, - import_type: import_type, - source: 'SomeImporter' - ) - - expect(Gitlab::Import::Logger) - .to receive(:error) - .with( - message: 'importer failed', - 'error.message': 'some error', - project_id: project.id, - import_type: import_type, - source: 'SomeImporter' - ) - - described_class.track(**arguments) - - expect(project.import_state.reload.status).to eq('started') - - expect(project.import_failures).not_to be_empty - expect(project.import_failures.last.exception_class).to eq('StandardError') - expect(project.import_failures.last.exception_message).to eq('some error') + it 'invokes a new instance and executes' do + expect(described_class).to receive(:new).with(**instance_arguments).and_return(instance) + expect(instance).to receive(:execute).with(**exe_arguments) + + described_class.track(**instance_arguments.merge(exe_arguments)) + end end - end - context 'when using the project as reference' do - context 'when it fails the import' do - let(:arguments) do + context 'with only necessary arguments utilizing defaults' do + let(:instance) { double(:failure_service) } + let(:instance_arguments) do { - project_id: project.id, exception: exception, - error_source: 'SomeImporter', - fail_import: true + import_state: nil, + project_id: nil, + error_source: nil } end - it_behaves_like 'logs the exception and fails the import' - end - - context 'when it does not fail the import' do - let(:arguments) do + let(:exe_arguments) do { - project_id: project.id, - exception: exception, - error_source: 'SomeImporter', - fail_import: false + fail_import: false, + metrics: false } end - it_behaves_like 'logs the exception and does not fail the import' + it 'invokes a new instance and executes' do + expect(described_class).to receive(:new).with(**instance_arguments).and_return(instance) + expect(instance).to receive(:execute).with(**exe_arguments) + + described_class.track(exception: exception) + end end end - context 'when using the import_state as reference' do - context 'when it fails the import' do - let(:arguments) do - { - import_state: import_state, - exception: exception, - error_source: 'SomeImporter', - fail_import: true - } + describe '#execute' do + subject(:service) { described_class.new(**base_arguments) } + + shared_examples 'logs the exception and fails the import' do + it 'when the failure does not abort the import' do + expect(Gitlab::ErrorTracking) + .to receive(:track_exception) + .with( + exception, + project_id: project.id, + import_type: import_type, + source: 'SomeImporter' + ) + + expect(Gitlab::Import::Logger) + .to receive(:error) + .with( + message: 'importer failed', + 'error.message': 'some error', + project_id: project.id, + import_type: import_type, + source: 'SomeImporter' + ) + + service.execute(**exe_arguments) + + expect(project.import_state.reload.status).to eq('failed') + + expect(project.import_failures).not_to be_empty + expect(project.import_failures.last.exception_class).to eq('StandardError') + expect(project.import_failures.last.exception_message).to eq('some error') end + end - it_behaves_like 'logs the exception and fails the import' + shared_examples 'logs the exception and does not fail the import' do + it 'when the failure does not abort the import' do + expect(Gitlab::ErrorTracking) + .to receive(:track_exception) + .with( + exception, + project_id: project.id, + import_type: import_type, + source: 'SomeImporter' + ) + + expect(Gitlab::Import::Logger) + .to receive(:error) + .with( + message: 'importer failed', + 'error.message': 'some error', + project_id: project.id, + import_type: import_type, + source: 'SomeImporter' + ) + + service.execute(**exe_arguments) + + expect(project.import_state.reload.status).to eq('started') + + expect(project.import_failures).not_to be_empty + expect(project.import_failures.last.exception_class).to eq('StandardError') + expect(project.import_failures.last.exception_message).to eq('some error') + end end - context 'when it does not fail the import' do - let(:arguments) do - { - import_state: import_state, - exception: exception, - error_source: 'SomeImporter', - fail_import: false - } + context 'when tracking metrics' do + let(:exe_arguments) { { fail_import: false, metrics: true } } + + it 'tracks the failed import' do + metrics = double(:metrics) + + expect(Gitlab::Import::Metrics).to receive(:new).with("#{project.import_type}_importer", project).and_return(metrics) + expect(metrics).to receive(:track_failed_import) + + service.execute(**exe_arguments) end + end + + context 'when using the project as reference' do + context 'when it fails the import' do + let(:exe_arguments) { { fail_import: true, metrics: false } } - it_behaves_like 'logs the exception and does not fail the import' + it_behaves_like 'logs the exception and fails the import' + end + + context 'when it does not fail the import' do + it_behaves_like 'logs the exception and does not fail the import' + end + end + + context 'when using the import_state as reference' do + let(:arguments) { { import_state: project.import_state } } + + context 'when it fails the import' do + let(:exe_arguments) { { fail_import: true, metrics: false } } + + it_behaves_like 'logs the exception and fails the import' + end + + context 'when it does not fail the import' do + it_behaves_like 'logs the exception and does not fail the import' + end end end end diff --git a/spec/lib/gitlab/import/metrics_spec.rb b/spec/lib/gitlab/import/metrics_spec.rb index 0a912427014..035294a620f 100644 --- a/spec/lib/gitlab/import/metrics_spec.rb +++ b/spec/lib/gitlab/import/metrics_spec.rb @@ -2,20 +2,67 @@ require 'spec_helper' -RSpec.describe Gitlab::Import::Metrics do +RSpec.describe Gitlab::Import::Metrics, :aggregate_failures do let(:importer) { :test_importer } - let(:project) { create(:project) } + let(:project) { build(:project, id: non_existing_record_id, created_at: Time.current) } let(:histogram) { double(:histogram) } let(:counter) { double(:counter) } subject { described_class.new(importer, project) } - describe '#report_import_time' do + before do + allow(Gitlab::Metrics).to receive(:counter) { counter } + allow(counter).to receive(:increment) + allow(histogram).to receive(:observe) + end + + describe '#track_start_import' do + context 'when project is not a github import' do + it 'does not emit importer metrics' do + expect(subject).not_to receive(:track_usage_event) + + subject.track_start_import + end + end + + context 'when project is a github import' do + before do + project.import_type = 'github' + end + + it 'emits importer metrics' do + expect(subject).to receive(:track_usage_event).with(:github_import_project_start, project.id) + + subject.track_start_import + end + end + end + + describe '#track_failed_import' do + context 'when project is not a github import' do + it 'does not emit importer metrics' do + expect(subject).not_to receive(:track_usage_event) + + subject.track_failed_import + end + end + + context 'when project is a github import' do + before do + project.import_type = 'github' + end + + it 'emits importer metrics' do + expect(subject).to receive(:track_usage_event).with(:github_import_project_failure, project.id) + + subject.track_failed_import + end + end + end + + describe '#track_finished_import' do before do - allow(Gitlab::Metrics).to receive(:counter) { counter } allow(Gitlab::Metrics).to receive(:histogram) { histogram } - allow(counter).to receive(:increment) - allow(counter).to receive(:observe) end it 'emits importer metrics' do @@ -32,9 +79,56 @@ RSpec.describe Gitlab::Import::Metrics do ) expect(counter).to receive(:increment) - expect(histogram).to receive(:observe).with({ importer: :test_importer }, anything) subject.track_finished_import + + expect(subject.duration).not_to be_nil + end + + context 'when project is not a github import' do + it 'does not emit importer metrics' do + expect(subject).not_to receive(:track_usage_event) + + subject.track_finished_import + + expect(histogram).to have_received(:observe).with({ importer: :test_importer }, anything) + end + end + + context 'when project is a github import' do + before do + project.import_type = 'github' + end + + it 'emits importer metrics' do + expect(subject).to receive(:track_usage_event).with(:github_import_project_success, project.id) + + subject.track_finished_import + + expect(histogram).to have_received(:observe).with({ project: project.full_path }, anything) + end + end + end + + describe '#issues_counter' do + it 'creates a counter for issues' do + expect(Gitlab::Metrics).to receive(:counter).with( + :test_importer_imported_issues_total, + 'The number of imported issues' + ) + + subject.issues_counter + end + end + + describe '#merge_requests_counter' do + it 'creates a counter for issues' do + expect(Gitlab::Metrics).to receive(:counter).with( + :test_importer_imported_merge_requests_total, + 'The number of imported merge (pull) requests' + ) + + subject.merge_requests_counter end end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 614aa55c3c5..10f0e687077 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -59,6 +59,7 @@ issues: - requirement - incident_management_issuable_escalation_status - pending_escalations +- customer_relations_contacts work_item_type: - issues events: @@ -272,6 +273,8 @@ ci_pipelines: - dast_profiles_pipeline - dast_site_profile - dast_site_profiles_pipeline +- package_build_infos +- package_file_build_infos ci_refs: - project - ci_pipelines @@ -322,7 +325,6 @@ integrations: - jira_tracker_data - zentao_tracker_data - issue_tracker_data -- open_project_tracker_data hooks: - project - web_hook_logs @@ -354,10 +356,7 @@ container_repositories: - name project: - external_status_checks -- taggings - base_tags -- topic_taggings -- topics_acts_as_taggable - project_topics - topics - chat_services @@ -593,6 +592,7 @@ project: - pending_builds - security_scans - ci_feature_usages +- bulk_import_exports award_emoji: - awardable - user diff --git a/spec/lib/gitlab/import_export/attributes_permitter_spec.rb b/spec/lib/gitlab/import_export/attributes_permitter_spec.rb index 36a831a785c..2b974f8985d 100644 --- a/spec/lib/gitlab/import_export/attributes_permitter_spec.rb +++ b/spec/lib/gitlab/import_export/attributes_permitter_spec.rb @@ -83,14 +83,22 @@ RSpec.describe Gitlab::ImportExport::AttributesPermitter do where(:relation_name, :permitted_attributes_defined) do :user | false :author | false - :ci_cd_settings | false - :issuable_sla | false - :push_rule | false + :ci_cd_settings | true :metrics_setting | true :project_badges | true :pipeline_schedules | true :error_tracking_setting | true :auto_devops | true + :boards | true + :custom_attributes | true + :labels | true + :protected_branches | true + :protected_tags | true + :create_access_levels | true + :merge_access_levels | true + :push_access_levels | true + :releases | true + :links | true end with_them do @@ -99,47 +107,11 @@ RSpec.describe Gitlab::ImportExport::AttributesPermitter do end describe 'included_attributes for Project' do - let(:prohibited_attributes) { %i[remote_url my_attributes my_ids token my_id test] } - subject { described_class.new } Gitlab::ImportExport::Config.new.to_h[:included_attributes].each do |relation_sym, permitted_attributes| context "for #{relation_sym}" do - let(:import_export_config) { Gitlab::ImportExport::Config.new.to_h } - let(:project_relation_factory) { Gitlab::ImportExport::Project::RelationFactory } - - let(:relation_hash) { (permitted_attributes + prohibited_attributes).map(&:to_s).zip([]).to_h } - let(:relation_name) { project_relation_factory.overrides[relation_sym]&.to_sym || relation_sym } - let(:relation_class) { project_relation_factory.relation_class(relation_name) } - let(:excluded_keys) { import_export_config.dig(:excluded_keys, relation_sym) || [] } - - let(:cleaned_hash) do - Gitlab::ImportExport::AttributeCleaner.new( - relation_hash: relation_hash, - relation_class: relation_class, - excluded_keys: excluded_keys - ).clean - end - - let(:permitted_hash) { subject.permit(relation_sym, relation_hash) } - - if described_class.new.permitted_attributes_defined?(relation_sym) - it 'contains only attributes that are defined as permitted in the import/export config' do - expect(permitted_hash.keys).to contain_exactly(*permitted_attributes.map(&:to_s)) - end - - it 'does not contain attributes that would be cleaned with AttributeCleaner' do - expect(cleaned_hash.keys).to include(*permitted_hash.keys) - end - - it 'does not contain prohibited attributes that are not related to given relation' do - expect(permitted_hash.keys).not_to include(*prohibited_attributes.map(&:to_s)) - end - else - it 'is disabled' do - expect(subject).not_to be_permitted_attributes_defined(relation_sym) - end - end + it_behaves_like 'a permitted attribute', relation_sym, permitted_attributes end end end diff --git a/spec/lib/gitlab/import_export/command_line_util_spec.rb b/spec/lib/gitlab/import_export/command_line_util_spec.rb index 39a10f87083..59c4e1083ae 100644 --- a/spec/lib/gitlab/import_export/command_line_util_spec.rb +++ b/spec/lib/gitlab/import_export/command_line_util_spec.rb @@ -8,6 +8,7 @@ RSpec.describe Gitlab::ImportExport::CommandLineUtil do let(:path) { "#{Dir.tmpdir}/symlink_test" } let(:archive) { 'spec/fixtures/symlink_export.tar.gz' } let(:shared) { Gitlab::ImportExport::Shared.new(nil) } + let(:tmpdir) { Dir.mktmpdir } subject do Class.new do @@ -26,6 +27,7 @@ RSpec.describe Gitlab::ImportExport::CommandLineUtil do after do FileUtils.rm_rf(path) + FileUtils.remove_entry(tmpdir) end it 'has the right mask for project.json' do @@ -55,7 +57,6 @@ RSpec.describe Gitlab::ImportExport::CommandLineUtil do describe '#gunzip' do it 'decompresses specified file' do - tmpdir = Dir.mktmpdir filename = 'labels.ndjson.gz' gz_filepath = "spec/fixtures/bulk_imports/gz/#{filename}" FileUtils.copy_file(gz_filepath, File.join(tmpdir, filename)) @@ -63,8 +64,6 @@ RSpec.describe Gitlab::ImportExport::CommandLineUtil do subject.gunzip(dir: tmpdir, filename: filename) expect(File.exist?(File.join(tmpdir, 'labels.ndjson'))).to eq(true) - - FileUtils.remove_entry(tmpdir) end context 'when exception occurs' do @@ -73,4 +72,33 @@ RSpec.describe Gitlab::ImportExport::CommandLineUtil do end end end + + describe '#tar_cf' do + let(:archive_dir) { Dir.mktmpdir } + + after do + FileUtils.remove_entry(archive_dir) + end + + it 'archives a folder without compression' do + archive_file = File.join(archive_dir, 'archive.tar') + + result = subject.tar_cf(archive: archive_file, dir: tmpdir) + + expect(result).to eq(true) + expect(File.exist?(archive_file)).to eq(true) + end + + context 'when something goes wrong' do + it 'raises an error' do + expect(Gitlab::Popen).to receive(:popen).and_return(['Error', 1]) + + klass = Class.new do + include Gitlab::ImportExport::CommandLineUtil + end.new + + expect { klass.tar_cf(archive: 'test', dir: 'test') }.to raise_error(Gitlab::ImportExport::Error, 'System call failed') + end + end + end end diff --git a/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb b/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb index 9e30564b437..d69d775fffb 100644 --- a/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb +++ b/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb @@ -115,7 +115,7 @@ RSpec.describe Gitlab::ImportExport::Json::StreamingSerializer do end it 'orders exported issues by custom column(relative_position)' do - expected_issues = exportable.issues.order_relative_position_desc.order(id: :desc).map(&:to_json) + expected_issues = exportable.issues.reorder(::Gitlab::Database.nulls_first_order('relative_position', 'DESC')).order(id: :desc).map(&:to_json) expect(json_writer).to receive(:write_relation_array).with(exportable_path, :issues, expected_issues) @@ -163,21 +163,10 @@ RSpec.describe Gitlab::ImportExport::Json::StreamingSerializer do stub_feature_flags(load_balancing_for_export_workers: true) end - context 'when enabled', :db_load_balancing do - it 'reads from replica' do - expect(Gitlab::Database::LoadBalancing::Session.current).to receive(:use_replicas_for_read_queries).and_call_original + it 'reads from replica' do + expect(Gitlab::Database::LoadBalancing::Session.current).to receive(:use_replicas_for_read_queries).and_call_original - subject.execute - end - end - - context 'when disabled' do - it 'reads from primary' do - allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(false) - expect(Gitlab::Database::LoadBalancing::Session.current).not_to receive(:use_replicas_for_read_queries) - - subject.execute - end + subject.execute end end diff --git a/spec/lib/gitlab/import_export/merge_request_parser_spec.rb b/spec/lib/gitlab/import_export/merge_request_parser_spec.rb index c558c12f581..550cefea805 100644 --- a/spec/lib/gitlab/import_export/merge_request_parser_spec.rb +++ b/spec/lib/gitlab/import_export/merge_request_parser_spec.rb @@ -13,9 +13,11 @@ RSpec.describe Gitlab::ImportExport::MergeRequestParser do create(:merge_request, source_project: forked_project, target_project: project) end + let(:diff_head_sha) { SecureRandom.hex(20) } + let(:parsed_merge_request) do described_class.new(project, - 'abcd', + diff_head_sha, merge_request, merge_request.as_json).parse! end @@ -34,14 +36,34 @@ RSpec.describe Gitlab::ImportExport::MergeRequestParser do expect(project.repository.branch_exists?(parsed_merge_request.target_branch)).to be true end - it 'parses a MR that has no source branch' do - allow_next_instance_of(described_class) do |instance| - allow(instance).to receive(:branch_exists?).and_call_original - allow(instance).to receive(:branch_exists?).with(merge_request.source_branch).and_return(false) - allow(instance).to receive(:fork_merge_request?).and_return(true) + # Source and target branch are only created when: fork_merge_request + context 'fork merge request' do + before do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:fork_merge_request?).and_return(true) + end + end + + it 'parses a MR that has no source branch' do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:branch_exists?).and_call_original + allow(instance).to receive(:branch_exists?).with(merge_request.source_branch).and_return(false) + end + + expect(parsed_merge_request).to eq(merge_request) end - expect(parsed_merge_request).to eq(merge_request) + it 'parses a MR that is closed' do + merge_request.update!(state: :closed, source_branch: 'new_branch') + + expect(project.repository.branch_exists?(parsed_merge_request.source_branch)).to be false + end + + it 'parses a MR that is merged' do + merge_request.update!(state: :merged, source_branch: 'new_branch') + + expect(project.repository.branch_exists?(parsed_merge_request.source_branch)).to be false + end end context 'when the merge request has diffs' do diff --git a/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb index 9325cdac9ed..5e4075c2b59 100644 --- a/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb @@ -30,18 +30,12 @@ RSpec.describe Gitlab::ImportExport::RelationTreeRestorer do subject { relation_tree_restorer.restore } shared_examples 'import project successfully' do - it 'restores project tree' do - expect(subject).to eq(true) - end - describe 'imported project' do - let(:project) { Project.find_by_path('project') } + it 'has the project attributes and relations', :aggregate_failures do + expect(subject).to eq(true) - before do - subject - end + project = Project.find_by_path('project') - it 'has the project attributes and relations' do expect(project.description).to eq('Nisi et repellendus ut enim quo accusamus vel magnam.') expect(project.labels.count).to eq(3) expect(project.boards.count).to eq(1) @@ -86,7 +80,10 @@ RSpec.describe Gitlab::ImportExport::RelationTreeRestorer do end context 'when restoring a project' do - let(:importable) { create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project') } + let_it_be(:importable, reload: true) do + create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project') + end + let(:importable_name) { 'project' } let(:importable_path) { 'project' } let(:object_builder) { Gitlab::ImportExport::Project::ObjectBuilder } @@ -108,8 +105,10 @@ RSpec.describe Gitlab::ImportExport::RelationTreeRestorer do it_behaves_like 'import project successfully' context 'logging of relations creation' do - let(:group) { create(:group) } - let(:importable) { create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project', group: group) } + let_it_be(:group) { create(:group) } + let_it_be(:importable) do + create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project', group: group) + end include_examples 'logging of relations creation' end @@ -120,6 +119,18 @@ RSpec.describe Gitlab::ImportExport::RelationTreeRestorer do let(:relation_reader) { Gitlab::ImportExport::Json::NdjsonReader.new(path) } it_behaves_like 'import project successfully' + + context 'when inside a group' do + let_it_be(:group) do + create(:group, :disabled_and_unoverridable) + end + + before do + importable.update!(shared_runners_enabled: false, group: group) + end + + it_behaves_like 'import project successfully' + end end context 'with invalid relations' do @@ -143,9 +154,10 @@ RSpec.describe Gitlab::ImportExport::RelationTreeRestorer do end context 'when restoring a group' do + let_it_be(:group) { create(:group) } + let_it_be(:importable) { create(:group, parent: group) } + let(:path) { 'spec/fixtures/lib/gitlab/import_export/group_exports/no_children/group.json' } - let(:group) { create(:group) } - let(:importable) { create(:group, parent: group) } let(:importable_name) { nil } let(:importable_path) { nil } let(:object_builder) { Gitlab::ImportExport::Group::ObjectBuilder } diff --git a/spec/lib/gitlab/import_export/snippet_repo_restorer_spec.rb b/spec/lib/gitlab/import_export/snippet_repo_restorer_spec.rb index c1661cf02b6..7d719b6028f 100644 --- a/spec/lib/gitlab/import_export/snippet_repo_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/snippet_repo_restorer_spec.rb @@ -29,6 +29,9 @@ RSpec.describe Gitlab::ImportExport::SnippetRepoRestorer do expect(restorer.restore).to be_truthy end.to change { SnippetRepository.count }.by(1) + snippet.repository.expire_method_caches(%i(exists?)) + expect(snippet.repository_exists?).to be_truthy + blob = snippet.repository.blob_at(snippet.default_branch, snippet.file_name) expect(blob).not_to be_nil expect(blob.data).to eq(snippet.content) diff --git a/spec/lib/gitlab/instrumentation/redis_spec.rb b/spec/lib/gitlab/instrumentation/redis_spec.rb index ebc2e92a0dd..900a079cdd2 100644 --- a/spec/lib/gitlab/instrumentation/redis_spec.rb +++ b/spec/lib/gitlab/instrumentation/redis_spec.rb @@ -76,7 +76,9 @@ RSpec.describe Gitlab::Instrumentation::Redis do details_row.merge(storage: 'Cache'), details_row.merge(storage: 'Queues'), details_row.merge(storage: 'SharedState'), - details_row.merge(storage: 'TraceChunks')) + details_row.merge(storage: 'TraceChunks'), + details_row.merge(storage: 'RateLimiting'), + details_row.merge(storage: 'Sessions')) end end end diff --git a/spec/lib/gitlab/instrumentation_helper_spec.rb b/spec/lib/gitlab/instrumentation_helper_spec.rb index 85daf50717c..52d3623c304 100644 --- a/spec/lib/gitlab/instrumentation_helper_spec.rb +++ b/spec/lib/gitlab/instrumentation_helper_spec.rb @@ -107,75 +107,50 @@ RSpec.describe Gitlab::InstrumentationHelper do end end - context 'when load balancing is enabled' do - before do - allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(true) - end - - it 'includes DB counts' do - subject - - expect(payload).to include(db_replica_count: 0, - db_replica_cached_count: 0, - db_primary_count: 0, - db_primary_cached_count: 0, - db_primary_wal_count: 0, - db_replica_wal_count: 0, - db_primary_wal_cached_count: 0, - db_replica_wal_cached_count: 0) - end - - context 'when replica caught up search was made' do - before do - Gitlab::SafeRequestStore[:caught_up_replica_pick_ok] = 2 - Gitlab::SafeRequestStore[:caught_up_replica_pick_fail] = 1 - end + it 'includes DB counts' do + subject - it 'includes related metrics' do - subject + expect(payload).to include(db_replica_count: 0, + db_replica_cached_count: 0, + db_primary_count: 0, + db_primary_cached_count: 0, + db_primary_wal_count: 0, + db_replica_wal_count: 0, + db_primary_wal_cached_count: 0, + db_replica_wal_cached_count: 0) + end - expect(payload).to include(caught_up_replica_pick_ok: 2) - expect(payload).to include(caught_up_replica_pick_fail: 1) - end + context 'when replica caught up search was made' do + before do + Gitlab::SafeRequestStore[:caught_up_replica_pick_ok] = 2 + Gitlab::SafeRequestStore[:caught_up_replica_pick_fail] = 1 end - context 'when only a single counter was updated' do - before do - Gitlab::SafeRequestStore[:caught_up_replica_pick_ok] = 1 - Gitlab::SafeRequestStore[:caught_up_replica_pick_fail] = nil - end - - it 'includes only that counter into logging' do - subject + it 'includes related metrics' do + subject - expect(payload).to include(caught_up_replica_pick_ok: 1) - expect(payload).not_to include(:caught_up_replica_pick_fail) - end + expect(payload).to include(caught_up_replica_pick_ok: 2) + expect(payload).to include(caught_up_replica_pick_fail: 1) end end - context 'when load balancing is disabled' do + context 'when only a single counter was updated' do before do - allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(false) + Gitlab::SafeRequestStore[:caught_up_replica_pick_ok] = 1 + Gitlab::SafeRequestStore[:caught_up_replica_pick_fail] = nil end - it 'does not include DB counts' do + it 'includes only that counter into logging' do subject - expect(payload).not_to include(db_replica_count: 0, - db_replica_cached_count: 0, - db_primary_count: 0, - db_primary_cached_count: 0, - db_primary_wal_count: 0, - db_replica_wal_count: 0, - db_primary_wal_cached_count: 0, - db_replica_wal_cached_count: 0) + expect(payload).to include(caught_up_replica_pick_ok: 1) + expect(payload).not_to include(:caught_up_replica_pick_fail) end end end - describe '.queue_duration_for_job' do - where(:enqueued_at, :created_at, :time_now, :expected_duration) do + describe 'duration calculations' do + where(:end_time, :start_time, :time_now, :expected_duration) do "2019-06-01T00:00:00.000+0000" | nil | "2019-06-01T02:00:00.000+0000" | 2.hours.to_f "2019-06-01T02:00:00.000+0000" | nil | "2019-06-01T02:00:00.001+0000" | 0.001 "2019-06-01T02:00:00.000+0000" | "2019-05-01T02:00:00.000+0000" | "2019-06-01T02:00:01.000+0000" | 1 @@ -189,15 +164,29 @@ RSpec.describe Gitlab::InstrumentationHelper do 0 | nil | "2019-10-23T12:13:16.000+0200" | nil -1 | nil | "2019-10-23T12:13:16.000+0200" | nil "2019-06-01T02:00:00.000+0000" | nil | "2019-06-01T00:00:00.000+0000" | 0 - Time.at(1571999233) | nil | "2019-10-25T12:29:16.000+0200" | 123 + Time.at(1571999233).utc | nil | "2019-10-25T12:29:16.000+0200" | 123 + end + + describe '.queue_duration_for_job' do + with_them do + let(:job) { { 'enqueued_at' => end_time, 'created_at' => start_time } } + + it "returns the correct duration" do + travel_to(Time.iso8601(time_now)) do + expect(described_class.queue_duration_for_job(job)).to eq(expected_duration) + end + end + end end - with_them do - let(:job) { { 'enqueued_at' => enqueued_at, 'created_at' => created_at } } + describe '.enqueue_latency_for_scheduled_job' do + with_them do + let(:job) { { 'enqueued_at' => end_time, 'scheduled_at' => start_time } } - it "returns the correct duration" do - Timecop.freeze(Time.iso8601(time_now)) do - expect(described_class.queue_duration_for_job(job)).to eq(expected_duration) + it "returns the correct duration" do + travel_to(Time.iso8601(time_now)) do + expect(described_class.enqueue_latency_for_scheduled_job(job)).to eq(expected_duration) + end end end end diff --git a/spec/lib/gitlab/kas_spec.rb b/spec/lib/gitlab/kas_spec.rb index 17d038ed16c..0fbb5f31210 100644 --- a/spec/lib/gitlab/kas_spec.rb +++ b/spec/lib/gitlab/kas_spec.rb @@ -70,30 +70,44 @@ RSpec.describe Gitlab::Kas do stub_config(gitlab_kas: { external_url: external_url }) end + let(:external_url) { 'xyz' } + subject { described_class.tunnel_url } - context 'external_url uses wss://' do - let(:external_url) { 'wss://kas.gitlab.example.com' } + context 'with a gitlab_kas.external_k8s_proxy_url setting' do + let(:external_k8s_proxy_url) { 'abc' } + + before do + stub_config(gitlab_kas: { external_k8s_proxy_url: external_k8s_proxy_url }) + end - it { is_expected.to eq('https://kas.gitlab.example.com/k8s-proxy') } + it { is_expected.to eq(external_k8s_proxy_url) } end - context 'external_url uses ws://' do - let(:external_url) { 'ws://kas.gitlab.example.com' } + context 'without a gitlab_kas.external_k8s_proxy_url setting' do + context 'external_url uses wss://' do + let(:external_url) { 'wss://kas.gitlab.example.com' } - it { is_expected.to eq('http://kas.gitlab.example.com/k8s-proxy') } - end + it { is_expected.to eq('https://kas.gitlab.example.com/k8s-proxy') } + end - context 'external_url uses grpcs://' do - let(:external_url) { 'grpcs://kas.gitlab.example.com' } + context 'external_url uses ws://' do + let(:external_url) { 'ws://kas.gitlab.example.com' } - it { is_expected.to eq('https://kas.gitlab.example.com/k8s-proxy') } - end + it { is_expected.to eq('http://kas.gitlab.example.com/k8s-proxy') } + end + + context 'external_url uses grpcs://' do + let(:external_url) { 'grpcs://kas.gitlab.example.com' } - context 'external_url uses grpc://' do - let(:external_url) { 'grpc://kas.gitlab.example.com' } + it { is_expected.to eq('https://kas.gitlab.example.com/k8s-proxy') } + end + + context 'external_url uses grpc://' do + let(:external_url) { 'grpc://kas.gitlab.example.com' } - it { is_expected.to eq('http://kas.gitlab.example.com/k8s-proxy') } + it { is_expected.to eq('http://kas.gitlab.example.com/k8s-proxy') } + end end end diff --git a/spec/lib/gitlab/mail_room/mail_room_spec.rb b/spec/lib/gitlab/mail_room/mail_room_spec.rb index a42da4ad3e0..0bd1a27c65e 100644 --- a/spec/lib/gitlab/mail_room/mail_room_spec.rb +++ b/spec/lib/gitlab/mail_room/mail_room_spec.rb @@ -93,7 +93,7 @@ RSpec.describe Gitlab::MailRoom do end describe 'setting up redis settings' do - let(:fake_redis_queues) { double(url: "localhost", sentinels: "yes, them", sentinels?: true) } + let(:fake_redis_queues) { double(url: "localhost", db: 99, sentinels: "yes, them", sentinels?: true) } before do allow(Gitlab::Redis::Queues).to receive(:new).and_return(fake_redis_queues) @@ -103,6 +103,7 @@ RSpec.describe Gitlab::MailRoom do config = described_class.enabled_configs.first expect(config[:redis_url]).to eq('localhost') + expect(config[:redis_db]).to eq(99) expect(config[:sentinels]).to eq('yes, them') end end diff --git a/spec/lib/gitlab/merge_requests/mergeability/check_result_spec.rb b/spec/lib/gitlab/merge_requests/mergeability/check_result_spec.rb new file mode 100644 index 00000000000..4f437e57600 --- /dev/null +++ b/spec/lib/gitlab/merge_requests/mergeability/check_result_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::MergeRequests::Mergeability::CheckResult do + subject(:check_result) { described_class } + + let(:time) { Time.current } + + around do |example| + freeze_time do + example.run + end + end + + describe '.default_payload' do + it 'returns the expected defaults' do + expect(check_result.default_payload).to eq({ last_run_at: time }) + end + end + + describe '.success' do + subject(:success) { check_result.success(payload: payload) } + + let(:payload) { {} } + + it 'creates a success result' do + expect(success.status).to eq described_class::SUCCESS_STATUS + end + + it 'uses the default payload' do + expect(success.payload).to eq described_class.default_payload + end + + context 'when given a payload' do + let(:payload) { { last_run_at: time + 1.day, test: 'test' } } + + it 'uses the payload passed' do + expect(success.payload).to eq payload + end + end + end + + describe '.failed' do + subject(:failed) { check_result.failed(payload: payload) } + + let(:payload) { {} } + + it 'creates a failure result' do + expect(failed.status).to eq described_class::FAILED_STATUS + end + + it 'uses the default payload' do + expect(failed.payload).to eq described_class.default_payload + end + + context 'when given a payload' do + let(:payload) { { last_run_at: time + 1.day, test: 'test' } } + + it 'uses the payload passed' do + expect(failed.payload).to eq payload + end + end + end + + describe '.from_hash' do + subject(:from_hash) { described_class.from_hash(hash) } + + let(:status) { described_class::SUCCESS_STATUS } + let(:payload) { { test: 'test' } } + let(:hash) do + { + status: status, + payload: payload + } + end + + it 'returns the expected status and payload' do + expect(from_hash.status).to eq status + expect(from_hash.payload).to eq payload + end + end + + describe '#to_hash' do + subject(:to_hash) { described_class.new(**hash).to_hash } + + let(:status) { described_class::SUCCESS_STATUS } + let(:payload) { { test: 'test' } } + let(:hash) do + { + status: status, + payload: payload + } + end + + it 'returns the expected hash' do + expect(to_hash).to eq hash + end + end + + describe '#failed?' do + subject(:failed) { described_class.new(status: status).failed? } + + context 'when it has failed' do + let(:status) { described_class::FAILED_STATUS } + + it 'returns true' do + expect(failed).to eq true + end + end + + context 'when it has succeeded' do + let(:status) { described_class::SUCCESS_STATUS } + + it 'returns false' do + expect(failed).to eq false + end + end + end + + describe '#success?' do + subject(:success) { described_class.new(status: status).success? } + + context 'when it has failed' do + let(:status) { described_class::FAILED_STATUS } + + it 'returns false' do + expect(success).to eq false + end + end + + context 'when it has succeeded' do + let(:status) { described_class::SUCCESS_STATUS } + + it 'returns true' do + expect(success).to eq true + end + end + end +end diff --git a/spec/lib/gitlab/merge_requests/mergeability/redis_interface_spec.rb b/spec/lib/gitlab/merge_requests/mergeability/redis_interface_spec.rb new file mode 100644 index 00000000000..e5475d04d86 --- /dev/null +++ b/spec/lib/gitlab/merge_requests/mergeability/redis_interface_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::MergeRequests::Mergeability::RedisInterface, :clean_gitlab_redis_shared_state do + subject(:redis_interface) { described_class.new } + + let(:merge_check) { double(cache_key: '13') } + let(:result_hash) { { 'test' => 'test' } } + let(:expected_key) { "#{merge_check.cache_key}:#{described_class::VERSION}" } + + describe '#save_check' do + it 'saves the hash' do + expect(Gitlab::Redis::SharedState.with { |redis| redis.get(expected_key) }).to be_nil + + redis_interface.save_check(merge_check: merge_check, result_hash: result_hash) + + expect(Gitlab::Redis::SharedState.with { |redis| redis.get(expected_key) }).to eq result_hash.to_json + end + end + + describe '#retrieve_check' do + it 'returns the hash' do + Gitlab::Redis::SharedState.with { |redis| redis.set(expected_key, result_hash.to_json) } + + expect(redis_interface.retrieve_check(merge_check: merge_check)).to eq result_hash + end + end +end diff --git a/spec/lib/gitlab/merge_requests/mergeability/results_store_spec.rb b/spec/lib/gitlab/merge_requests/mergeability/results_store_spec.rb new file mode 100644 index 00000000000..d376dcb5b18 --- /dev/null +++ b/spec/lib/gitlab/merge_requests/mergeability/results_store_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::MergeRequests::Mergeability::ResultsStore do + subject(:results_store) { described_class.new(merge_request: merge_request, interface: interface) } + + let(:merge_check) { double } + let(:interface) { double } + let(:merge_request) { double } + + describe '#read' do + it 'calls #retrieve on the interface' do + expect(interface).to receive(:retrieve_check).with(merge_check: merge_check) + + results_store.read(merge_check: merge_check) + end + end + + describe '#write' do + let(:result_hash) { double } + + it 'calls #save_check on the interface' do + expect(interface).to receive(:save_check).with(merge_check: merge_check, result_hash: result_hash) + + results_store.write(merge_check: merge_check, result_hash: result_hash) + end + end +end diff --git a/spec/lib/gitlab/metrics/exporter/web_exporter_spec.rb b/spec/lib/gitlab/metrics/exporter/web_exporter_spec.rb index ce98c807e2e..9deaecbf41b 100644 --- a/spec/lib/gitlab/metrics/exporter/web_exporter_spec.rb +++ b/spec/lib/gitlab/metrics/exporter/web_exporter_spec.rb @@ -30,6 +30,15 @@ RSpec.describe Gitlab::Metrics::Exporter::WebExporter do expect(readiness_probe.json).to include(status: 'ok') expect(readiness_probe.json).to include('web_exporter' => [{ 'status': 'ok' }]) end + + it 'initializes request metrics', :prometheus do + expect(Gitlab::Metrics::RailsSlis).to receive(:initialize_request_slis_if_needed!).and_call_original + + http = Net::HTTP.new(exporter.server.config[:BindAddress], exporter.server.config[:Port]) + response = http.request(Net::HTTP::Get.new('/metrics')) + + expect(response.body).to include('gitlab_sli:rails_request_apdex') + end end describe '#mark_as_not_running!' do diff --git a/spec/lib/gitlab/metrics/instrumentation_spec.rb b/spec/lib/gitlab/metrics/instrumentation_spec.rb deleted file mode 100644 index b15e06a0861..00000000000 --- a/spec/lib/gitlab/metrics/instrumentation_spec.rb +++ /dev/null @@ -1,342 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Metrics::Instrumentation do - let(:env) { {} } - let(:transaction) { Gitlab::Metrics::WebTransaction.new(env) } - - before do - @dummy = Class.new do - def self.foo(text = 'foo') - text - end - - def self.wat(text = 'wat') - text - end - private_class_method :wat - - class << self - def buzz(text = 'buzz') - text - end - private :buzz - - def flaky(text = 'flaky') - text - end - protected :flaky - end - - def bar(text = 'bar') - text - end - - def wadus(text = 'wadus') - text - end - private :wadus - - def chaf(text = 'chaf') - text - end - protected :chaf - end - - allow(@dummy).to receive(:name).and_return('Dummy') - end - - describe '.series' do - it 'returns a String' do - expect(described_class.series).to be_an_instance_of(String) - end - end - - describe '.configure' do - it 'yields self' do - described_class.configure do |c| - expect(c).to eq(described_class) - end - end - end - - describe '.instrument_method' do - describe 'with metrics enabled' do - before do - allow(Gitlab::Metrics).to receive(:enabled?).and_return(true) - - described_class.instrument_method(@dummy, :foo) - end - - it 'instruments the Class' do - target = @dummy.singleton_class - - expect(described_class.instrumented?(target)).to eq(true) - end - - it 'defines a proxy method' do - mod = described_class.proxy_module(@dummy.singleton_class) - - expect(mod.method_defined?(:foo)).to eq(true) - end - - it 'calls the instrumented method with the correct arguments' do - expect(@dummy.foo).to eq('foo') - end - - it 'tracks the call duration upon calling the method' do - allow(Gitlab::Metrics).to receive(:method_call_threshold) - .and_return(0) - - allow(described_class).to receive(:transaction) - .and_return(transaction) - - expect_next_instance_of(Gitlab::Metrics::MethodCall) do |instance| - expect(instance).to receive(:measure) - end - - @dummy.foo - end - - it 'does not track method calls below a given duration threshold' do - allow(Gitlab::Metrics).to receive(:method_call_threshold) - .and_return(100) - - expect(transaction).not_to receive(:add_metric) - - @dummy.foo - end - - it 'generates a method with the correct arity when using methods without arguments' do - dummy = Class.new do - def self.test; end - end - - described_class.instrument_method(dummy, :test) - - expect(dummy.method(:test).arity).to eq(0) - end - - describe 'when a module is instrumented multiple times' do - it 'calls the instrumented method with the correct arguments' do - described_class.instrument_method(@dummy, :foo) - - expect(@dummy.foo).to eq('foo') - end - end - end - - describe 'with metrics disabled' do - before do - allow(Gitlab::Metrics).to receive(:enabled?).and_return(false) - end - - it 'does not instrument the method' do - described_class.instrument_method(@dummy, :foo) - - target = @dummy.singleton_class - - expect(described_class.instrumented?(target)).to eq(false) - end - end - end - - describe '.instrument_instance_method' do - describe 'with metrics enabled' do - before do - allow(Gitlab::Metrics).to receive(:enabled?).and_return(true) - - described_class - .instrument_instance_method(@dummy, :bar) - end - - it 'instruments instances of the Class' do - expect(described_class.instrumented?(@dummy)).to eq(true) - end - - it 'defines a proxy method' do - mod = described_class.proxy_module(@dummy) - - expect(mod.method_defined?(:bar)).to eq(true) - end - - it 'calls the instrumented method with the correct arguments' do - expect(@dummy.new.bar).to eq('bar') - end - - it 'tracks the call duration upon calling the method' do - allow(Gitlab::Metrics).to receive(:method_call_threshold) - .and_return(0) - - allow(described_class).to receive(:transaction) - .and_return(transaction) - - expect_next_instance_of(Gitlab::Metrics::MethodCall) do |instance| - expect(instance).to receive(:measure) - end - - @dummy.new.bar - end - - it 'does not track method calls below a given duration threshold' do - allow(Gitlab::Metrics).to receive(:method_call_threshold) - .and_return(100) - - expect(transaction).not_to receive(:add_metric) - - @dummy.new.bar - end - end - - describe 'with metrics disabled' do - before do - allow(Gitlab::Metrics).to receive(:enabled?).and_return(false) - end - - it 'does not instrument the method' do - described_class - .instrument_instance_method(@dummy, :bar) - - expect(described_class.instrumented?(@dummy)).to eq(false) - end - end - end - - describe '.instrument_class_hierarchy' do - before do - allow(Gitlab::Metrics).to receive(:enabled?).and_return(true) - - @child1 = Class.new(@dummy) do - def self.child1_foo; end - - def child1_bar; end - end - - @child2 = Class.new(@child1) do - def self.child2_foo; end - - def child2_bar; end - end - end - - it 'recursively instruments a class hierarchy' do - described_class.instrument_class_hierarchy(@dummy) - - expect(described_class.instrumented?(@child1.singleton_class)).to eq(true) - expect(described_class.instrumented?(@child2.singleton_class)).to eq(true) - - expect(described_class.instrumented?(@child1)).to eq(true) - expect(described_class.instrumented?(@child2)).to eq(true) - end - - it 'does not instrument the root module' do - described_class.instrument_class_hierarchy(@dummy) - - expect(described_class.instrumented?(@dummy)).to eq(false) - end - end - - describe '.instrument_methods' do - before do - allow(Gitlab::Metrics).to receive(:enabled?).and_return(true) - end - - it 'instruments all public class methods' do - described_class.instrument_methods(@dummy) - - expect(described_class.instrumented?(@dummy.singleton_class)).to eq(true) - expect(@dummy.method(:foo).source_location.first).to match(/instrumentation\.rb/) - expect(@dummy.public_methods).to include(:foo) - end - - it 'instruments all protected class methods' do - described_class.instrument_methods(@dummy) - - expect(described_class.instrumented?(@dummy.singleton_class)).to eq(true) - expect(@dummy.method(:flaky).source_location.first).to match(/instrumentation\.rb/) - expect(@dummy.protected_methods).to include(:flaky) - end - - it 'instruments all private class methods' do - described_class.instrument_methods(@dummy) - - expect(described_class.instrumented?(@dummy.singleton_class)).to eq(true) - expect(@dummy.method(:buzz).source_location.first).to match(/instrumentation\.rb/) - expect(@dummy.private_methods).to include(:buzz) - expect(@dummy.private_methods).to include(:wat) - end - - it 'only instruments methods directly defined in the module' do - mod = Module.new do - def kittens - end - end - - @dummy.extend(mod) - - described_class.instrument_methods(@dummy) - - expect(@dummy).not_to respond_to(:_original_kittens) - end - - it 'can take a block to determine if a method should be instrumented' do - described_class.instrument_methods(@dummy) do - false - end - - expect(@dummy).not_to respond_to(:_original_foo) - end - end - - describe '.instrument_instance_methods' do - before do - allow(Gitlab::Metrics).to receive(:enabled?).and_return(true) - end - - it 'instruments all public instance methods' do - described_class.instrument_instance_methods(@dummy) - - expect(described_class.instrumented?(@dummy)).to eq(true) - expect(@dummy.new.method(:bar).source_location.first).to match(/instrumentation\.rb/) - expect(@dummy.public_instance_methods).to include(:bar) - end - - it 'instruments all protected instance methods' do - described_class.instrument_instance_methods(@dummy) - - expect(described_class.instrumented?(@dummy)).to eq(true) - expect(@dummy.new.method(:chaf).source_location.first).to match(/instrumentation\.rb/) - expect(@dummy.protected_instance_methods).to include(:chaf) - end - - it 'instruments all private instance methods' do - described_class.instrument_instance_methods(@dummy) - - expect(described_class.instrumented?(@dummy)).to eq(true) - expect(@dummy.new.method(:wadus).source_location.first).to match(/instrumentation\.rb/) - expect(@dummy.private_instance_methods).to include(:wadus) - end - - it 'only instruments methods directly defined in the module' do - mod = Module.new do - def kittens - end - end - - @dummy.include(mod) - - described_class.instrument_instance_methods(@dummy) - - expect(@dummy.new.method(:kittens).source_location.first).not_to match(/instrumentation\.rb/) - end - - it 'can take a block to determine if a method should be instrumented' do - described_class.instrument_instance_methods(@dummy) do - false - end - - expect(@dummy.new.method(:bar).source_location.first).not_to match(/instrumentation\.rb/) - end - end -end diff --git a/spec/lib/gitlab/metrics/rails_slis_spec.rb b/spec/lib/gitlab/metrics/rails_slis_spec.rb new file mode 100644 index 00000000000..16fcb9d46a2 --- /dev/null +++ b/spec/lib/gitlab/metrics/rails_slis_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Gitlab::Metrics::RailsSlis do + # Limit what routes we'll initialize so we don't have to load the entire thing + before do + api_route = API::API.routes.find do |route| + API::Base.endpoint_id_for_route(route) == "GET /api/:version/version" + end + + allow(Gitlab::RequestEndpoints).to receive(:all_api_endpoints).and_return([api_route]) + allow(Gitlab::RequestEndpoints).to receive(:all_controller_actions).and_return([[ProjectsController, 'show']]) + end + + describe '.initialize_request_slis_if_needed!' do + it "initializes the SLI for all possible endpoints if they weren't" do + possible_labels = [ + { + endpoint_id: "GET /api/:version/version", + feature_category: :not_owned + }, + { + endpoint_id: "ProjectsController#show", + feature_category: :projects + } + ] + + expect(Gitlab::Metrics::Sli).to receive(:initialized?).with(:rails_request_apdex) { false } + expect(Gitlab::Metrics::Sli).to receive(:initialize_sli).with(:rails_request_apdex, array_including(*possible_labels)).and_call_original + + described_class.initialize_request_slis_if_needed! + end + + it 'does not initialize the SLI if they were initialized already' do + expect(Gitlab::Metrics::Sli).to receive(:initialized?).with(:rails_request_apdex) { true } + expect(Gitlab::Metrics::Sli).not_to receive(:initialize_sli) + + described_class.initialize_request_slis_if_needed! + end + + it 'does not initialize anything if the feature flag is disabled' do + stub_feature_flags(request_apdex_counters: false) + + expect(Gitlab::Metrics::Sli).not_to receive(:initialize_sli) + expect(Gitlab::Metrics::Sli).not_to receive(:initialized?) + + described_class.initialize_request_slis_if_needed! + end + end + + describe '.request_apdex' do + it 'returns the initialized request apdex SLI object' do + described_class.initialize_request_slis_if_needed! + + expect(described_class.request_apdex).to be_initialized + end + end +end diff --git a/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb b/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb index 9d5c4bdf9e2..5870f9a8f68 100644 --- a/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb +++ b/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb @@ -36,6 +36,7 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do it 'tracks request count and duration' do expect(described_class).to receive_message_chain(:http_requests_total, :increment).with(method: 'get', status: '200', feature_category: 'unknown') expect(described_class).to receive_message_chain(:http_request_duration_seconds, :observe).with({ method: 'get' }, a_positive_execution_time) + expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with(labels: { feature_category: 'unknown', endpoint_id: 'unknown' }, success: true) subject.call(env) end @@ -70,7 +71,7 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do expect(described_class).not_to receive(:http_health_requests_total) expect(described_class) .to receive_message_chain(:http_request_duration_seconds, :observe) - .with({ method: 'get' }, a_positive_execution_time) + .with({ method: 'get' }, a_positive_execution_time) subject.call(env) end @@ -82,9 +83,10 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do context '@app.call returns an error code' do let(:status) { '500' } - it 'tracks count but not duration' do + it 'tracks count but not duration or apdex' do expect(described_class).to receive_message_chain(:http_requests_total, :increment).with(method: 'get', status: '500', feature_category: 'unknown') expect(described_class).not_to receive(:http_request_duration_seconds) + expect(Gitlab::Metrics::RailsSlis).not_to receive(:request_apdex) subject.call(env) end @@ -104,20 +106,23 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do expect(described_class).to receive_message_chain(:rack_uncaught_errors_count, :increment) expect(described_class).to receive_message_chain(:http_requests_total, :increment).with(method: 'get', status: 'undefined', feature_category: 'unknown') expect(described_class.http_request_duration_seconds).not_to receive(:observe) + expect(Gitlab::Metrics::RailsSlis).not_to receive(:request_apdex) expect { subject.call(env) }.to raise_error(StandardError) end end - context 'feature category header' do - context 'when a feature category context is present' do + context 'application context' do + context 'when a context is present' do before do - ::Gitlab::ApplicationContext.push(feature_category: 'issue_tracking') + ::Gitlab::ApplicationContext.push(feature_category: 'issue_tracking', caller_id: 'IssuesController#show') end - it 'adds the feature category to the labels for http_requests_total' do + it 'adds the feature category to the labels for required metrics' do expect(described_class).to receive_message_chain(:http_requests_total, :increment).with(method: 'get', status: '200', feature_category: 'issue_tracking') expect(described_class).not_to receive(:http_health_requests_total) + expect(Gitlab::Metrics::RailsSlis.request_apdex) + .to receive(:increment).with(labels: { feature_category: 'issue_tracking', endpoint_id: 'IssuesController#show' }, success: true) subject.call(env) end @@ -127,6 +132,7 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do expect(described_class).to receive_message_chain(:http_health_requests_total, :increment).with(method: 'get', status: '200') expect(described_class).not_to receive(:http_requests_total) + expect(Gitlab::Metrics::RailsSlis).not_to receive(:request_apdex) subject.call(env) end @@ -140,19 +146,180 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do it 'adds the feature category to the labels for http_requests_total' do expect(described_class).to receive_message_chain(:http_requests_total, :increment).with(method: 'get', status: 'undefined', feature_category: 'issue_tracking') + expect(Gitlab::Metrics::RailsSlis).not_to receive(:request_apdex) expect { subject.call(env) }.to raise_error(StandardError) end end - context 'when the feature category context is not available' do - it 'sets the feature category to unknown' do + context 'when the context is not available' do + it 'sets the required labels to unknown' do expect(described_class).to receive_message_chain(:http_requests_total, :increment).with(method: 'get', status: '200', feature_category: 'unknown') expect(described_class).not_to receive(:http_health_requests_total) + expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with(labels: { feature_category: 'unknown', endpoint_id: 'unknown' }, success: true) subject.call(env) end end + + context 'SLI satisfactory' do + where(:request_urgency_name, :duration, :success) do + [ + [:high, 0.1, true], + [:high, 0.25, false], + [:high, 0.3, false], + [:medium, 0.3, true], + [:medium, 0.5, false], + [:medium, 0.6, false], + [:default, 0.6, true], + [:default, 1.0, false], + [:default, 1.2, false], + [:low, 4.5, true], + [:low, 5.0, false], + [:low, 6, false] + ] + end + + with_them do + context 'Grape API handler having expected duration setup' do + let(:api_handler) do + request_urgency = request_urgency_name + Class.new(::API::Base) do + feature_category :hello_world, ['/projects/:id/archive'] + urgency request_urgency, ['/projects/:id/archive'] + end + end + + let(:endpoint) do + route = double(:route, request_method: 'GET', path: '/:version/projects/:id/archive(.:format)') + double(:endpoint, route: route, + options: { for: api_handler, path: [":id/archive"] }, + namespace: "/projects") + end + + let(:env) { { 'api.endpoint' => endpoint, 'REQUEST_METHOD' => 'GET' } } + + before do + ::Gitlab::ApplicationContext.push(feature_category: 'hello_world', caller_id: 'GET /projects/:id/archive') + allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(100, 100 + duration) + end + + it "captures SLI metrics" do + expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with( + labels: { feature_category: 'hello_world', endpoint_id: 'GET /projects/:id/archive' }, + success: success + ) + subject.call(env) + end + end + + context 'Rails controller having expected duration setup' do + let(:controller) do + request_urgency = request_urgency_name + Class.new(ApplicationController) do + feature_category :hello_world, [:index, :show] + urgency request_urgency, [:index, :show] + end + end + + let(:env) do + controller_instance = controller.new + controller_instance.action_name = :index + { 'action_controller.instance' => controller_instance, 'REQUEST_METHOD' => 'GET' } + end + + before do + ::Gitlab::ApplicationContext.push(feature_category: 'hello_world', caller_id: 'AnonymousController#index') + allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(100, 100 + duration) + end + + it "captures SLI metrics" do + expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with( + labels: { feature_category: 'hello_world', endpoint_id: 'AnonymousController#index' }, + success: success + ) + subject.call(env) + end + end + end + + context 'Grape API without expected duration' do + let(:endpoint) do + route = double(:route, request_method: 'GET', path: '/:version/projects/:id/archive(.:format)') + double(:endpoint, route: route, + options: { for: api_handler, path: [":id/archive"] }, + namespace: "/projects") + end + + let(:env) { { 'api.endpoint' => endpoint, 'REQUEST_METHOD' => 'GET' } } + + let(:api_handler) { Class.new(::API::Base) } + + it "falls back request's expectation to medium (1 second)" do + allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(100, 100.9) + expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with( + labels: { feature_category: 'unknown', endpoint_id: 'unknown' }, + success: true + ) + subject.call(env) + + allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(100, 101) + expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with( + labels: { feature_category: 'unknown', endpoint_id: 'unknown' }, + success: false + ) + subject.call(env) + end + end + + context 'Rails controller without expected duration' do + let(:controller) { Class.new(ApplicationController) } + + let(:env) do + controller_instance = controller.new + controller_instance.action_name = :index + { 'action_controller.instance' => controller_instance, 'REQUEST_METHOD' => 'GET' } + end + + it "falls back request's expectation to medium (1 second)" do + allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(100, 100.9) + expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with( + labels: { feature_category: 'unknown', endpoint_id: 'unknown' }, + success: true + ) + subject.call(env) + + allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(100, 101) + expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with( + labels: { feature_category: 'unknown', endpoint_id: 'unknown' }, + success: false + ) + subject.call(env) + end + end + + context 'An unknown request' do + let(:env) do + { 'REQUEST_METHOD' => 'GET' } + end + + it "falls back request's expectation to medium (1 second)" do + allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(100, 100.9) + expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with( + labels: { feature_category: 'unknown', endpoint_id: 'unknown' }, + success: true + ) + subject.call(env) + + allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(100, 101) + expect(Gitlab::Metrics::RailsSlis.request_apdex).to receive(:increment).with( + labels: { feature_category: 'unknown', endpoint_id: 'unknown' }, + success: false + ) + subject.call(env) + end + end + end end describe '.initialize_metrics', :prometheus do @@ -181,8 +348,8 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do end it 'has every label in config/feature_categories.yml' do - defaults = [described_class::FEATURE_CATEGORY_DEFAULT, 'not_owned'] - feature_categories = YAML.load_file(Rails.root.join('config', 'feature_categories.yml')).map(&:strip) + defaults + defaults = [::Gitlab::FeatureCategories::FEATURE_CATEGORY_DEFAULT, 'not_owned'] + feature_categories = Gitlab::FeatureCategories.default.categories + defaults expect(described_class::FEATURE_CATEGORIES_TO_INITIALIZE).to all(be_in(feature_categories)) end diff --git a/spec/lib/gitlab/metrics/sli_spec.rb b/spec/lib/gitlab/metrics/sli_spec.rb new file mode 100644 index 00000000000..8ba4bf29568 --- /dev/null +++ b/spec/lib/gitlab/metrics/sli_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Metrics::Sli do + let(:prometheus) { double("prometheus") } + + before do + stub_const("Gitlab::Metrics", prometheus) + end + + describe 'Class methods' do + before do + described_class.instance_variable_set(:@known_slis, nil) + end + + describe '.[]' do + it 'warns about an uninitialized SLI but returns and stores a new one' do + sli = described_class[:bar] + + expect(described_class[:bar]).to be(sli) + end + + it 'returns the same object for multiple accesses' do + sli = described_class.initialize_sli(:huzzah, []) + + 2.times do + expect(described_class[:huzzah]).to be(sli) + end + end + end + + describe '.initialized?' do + before do + fake_total_counter(:boom) + fake_success_counter(:boom) + end + + it 'is true when an SLI was initialized with labels' do + expect { described_class.initialize_sli(:boom, [{ hello: :world }]) } + .to change { described_class.initialized?(:boom) }.from(false).to(true) + end + + it 'is false when an SLI was not initialized with labels' do + expect { described_class.initialize_sli(:boom, []) } + .not_to change { described_class.initialized?(:boom) }.from(false) + end + end + end + + describe '#initialize_counters' do + it 'initializes counters for the passed label combinations' do + counters = [fake_total_counter(:hey), fake_success_counter(:hey)] + + described_class.new(:hey).initialize_counters([{ foo: 'bar' }, { foo: 'baz' }]) + + expect(counters).to all(have_received(:get).with({ foo: 'bar' })) + expect(counters).to all(have_received(:get).with({ foo: 'baz' })) + end + end + + describe "#increment" do + let!(:sli) { described_class.new(:heyo) } + let!(:total_counter) { fake_total_counter(:heyo) } + let!(:success_counter) { fake_success_counter(:heyo) } + + it 'increments both counters for labels successes' do + sli.increment(labels: { hello: "world" }, success: true) + + expect(total_counter).to have_received(:increment).with({ hello: 'world' }) + expect(success_counter).to have_received(:increment).with({ hello: 'world' }) + end + + it 'only increments the total counters for labels when not successful' do + sli.increment(labels: { hello: "world" }, success: false) + + expect(total_counter).to have_received(:increment).with({ hello: 'world' }) + expect(success_counter).not_to have_received(:increment).with({ hello: 'world' }) + end + end + + def fake_prometheus_counter(name) + fake_counter = double("prometheus counter: #{name}") + + allow(fake_counter).to receive(:get) + allow(fake_counter).to receive(:increment) + allow(prometheus).to receive(:counter).with(name.to_sym, anything).and_return(fake_counter) + + fake_counter + end + + def fake_total_counter(name) + fake_prometheus_counter("gitlab_sli:#{name}:total") + end + + def fake_success_counter(name) + fake_prometheus_counter("gitlab_sli:#{name}:success_total") + 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 3ffbcbea03c..a8e4f039da4 100644 --- a/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb +++ b/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do let(:env) { {} } let(:subscriber) { described_class.new } - let(:connection) { ActiveRecord::Base.connection } + let(:connection) { ActiveRecord::Base.retrieve_connection } let(:db_config_name) { ::Gitlab::Database.db_config_name(connection) } describe '#transaction' do @@ -135,7 +135,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do end it_behaves_like 'record ActiveRecord metrics' - it_behaves_like 'store ActiveRecord info in RequestStore' + it_behaves_like 'store ActiveRecord info in RequestStore', :primary end end @@ -195,10 +195,6 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do with_them do let(:payload) { { name: name, sql: sql(sql_query, comments: comments), connection: connection } } - before do - allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(true) - end - context 'query using a connection to a replica' do before do allow(Gitlab::Database::LoadBalancing).to receive(:db_role_for_connection).and_return(:replica) diff --git a/spec/lib/gitlab/metrics/subscribers/load_balancing_spec.rb b/spec/lib/gitlab/metrics/subscribers/load_balancing_spec.rb index 21a6573c6fd..bc6effd0438 100644 --- a/spec/lib/gitlab/metrics/subscribers/load_balancing_spec.rb +++ b/spec/lib/gitlab/metrics/subscribers/load_balancing_spec.rb @@ -5,10 +5,6 @@ require 'spec_helper' RSpec.describe Gitlab::Metrics::Subscribers::LoadBalancing, :request_store do let(:subscriber) { described_class.new } - before do - allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(true) - end - describe '#caught_up_replica_pick' do shared_examples 'having payload result value' do |result, counter_name| subject { subscriber.caught_up_replica_pick(event) } diff --git a/spec/lib/gitlab/metrics/web_transaction_spec.rb b/spec/lib/gitlab/metrics/web_transaction_spec.rb index 5261d04c879..9e22dccb2a2 100644 --- a/spec/lib/gitlab/metrics/web_transaction_spec.rb +++ b/spec/lib/gitlab/metrics/web_transaction_spec.rb @@ -32,7 +32,7 @@ RSpec.describe Gitlab::Metrics::WebTransaction do it 'measures with correct labels and value' do value = 1 - expect(prometheus_metric).to receive(metric_method).with({ controller: 'TestController', action: 'show', feature_category: '' }, value) + expect(prometheus_metric).to receive(metric_method).with({ controller: 'TestController', action: 'show', feature_category: ::Gitlab::FeatureCategories::FEATURE_CATEGORY_DEFAULT }, value) transaction.send(metric_method, :bau, value) end @@ -105,6 +105,9 @@ RSpec.describe Gitlab::Metrics::WebTransaction do namespace: "/projects") env['api.endpoint'] = endpoint + + # This is needed since we're not actually making a request, which would trigger the controller pushing to the context + ::Gitlab::ApplicationContext.push(feature_category: 'projects') end it 'provides labels with the method and path of the route in the grape endpoint' do @@ -129,7 +132,7 @@ RSpec.describe Gitlab::Metrics::WebTransaction do include_context 'ActionController request' it 'tags a transaction with the name and action of a controller' do - expect(transaction.labels).to eq({ controller: 'TestController', action: 'show', feature_category: '' }) + expect(transaction.labels).to eq({ controller: 'TestController', action: 'show', feature_category: ::Gitlab::FeatureCategories::FEATURE_CATEGORY_DEFAULT }) end it 'contains only the labels defined for transactions' do @@ -140,7 +143,7 @@ RSpec.describe Gitlab::Metrics::WebTransaction do let(:request) { double(:request, format: double(:format, ref: :json)) } it 'appends the mime type to the transaction action' do - expect(transaction.labels).to eq({ controller: 'TestController', action: 'show.json', feature_category: '' }) + expect(transaction.labels).to eq({ controller: 'TestController', action: 'show.json', feature_category: ::Gitlab::FeatureCategories::FEATURE_CATEGORY_DEFAULT }) end end @@ -148,13 +151,15 @@ RSpec.describe Gitlab::Metrics::WebTransaction do let(:request) { double(:request, format: double(:format, ref: 'http://example.com')) } it 'does not append the MIME type to the transaction action' do - expect(transaction.labels).to eq({ controller: 'TestController', action: 'show', feature_category: '' }) + expect(transaction.labels).to eq({ controller: 'TestController', action: 'show', feature_category: ::Gitlab::FeatureCategories::FEATURE_CATEGORY_DEFAULT }) end end context 'when the feature category is known' do it 'includes it in the feature category label' do - expect(controller_class).to receive(:feature_category_for_action).with('show').and_return(:source_code_management) + # This is needed since we're not actually making a request, which would trigger the controller pushing to the context + ::Gitlab::ApplicationContext.push(feature_category: 'source_code_management') + expect(transaction.labels).to eq({ controller: 'TestController', action: 'show', feature_category: "source_code_management" }) end end diff --git a/spec/lib/gitlab/middleware/go_spec.rb b/spec/lib/gitlab/middleware/go_spec.rb index 7bac041cd65..0ce95fdb5af 100644 --- a/spec/lib/gitlab/middleware/go_spec.rb +++ b/spec/lib/gitlab/middleware/go_spec.rb @@ -98,7 +98,7 @@ RSpec.describe Gitlab::Middleware::Go do end end - context 'without access to the project' do + context 'without access to the project', :sidekiq_inline do before do project.team.find_member(current_user).destroy end diff --git a/spec/lib/gitlab/middleware/multipart/handler_spec.rb b/spec/lib/gitlab/middleware/multipart/handler_spec.rb index aac3f00defe..53b59b042e2 100644 --- a/spec/lib/gitlab/middleware/multipart/handler_spec.rb +++ b/spec/lib/gitlab/middleware/multipart/handler_spec.rb @@ -16,6 +16,7 @@ RSpec.describe Gitlab::Middleware::Multipart::Handler do ::Gitlab.config.uploads.storage_path, ::JobArtifactUploader.workhorse_upload_path, ::LfsObjectUploader.workhorse_upload_path, + ::DependencyProxy::FileUploader.workhorse_upload_path, File.join(Rails.root, 'public/uploads/tmp') ] end diff --git a/spec/lib/gitlab/middleware/speedscope_spec.rb b/spec/lib/gitlab/middleware/speedscope_spec.rb index bb830a2fbda..c1d452f69f8 100644 --- a/spec/lib/gitlab/middleware/speedscope_spec.rb +++ b/spec/lib/gitlab/middleware/speedscope_spec.rb @@ -46,7 +46,7 @@ RSpec.describe Gitlab::Middleware::Speedscope do allow(env).to receive(:[]).with('warden').and_return(double('Warden', user: create(:admin))) end - it 'runs StackProf and returns a flamegraph' do + it 'returns a flamegraph' do expect(StackProf).to receive(:run).and_call_original status, headers, body = middleware.call(env) @@ -55,6 +55,56 @@ RSpec.describe Gitlab::Middleware::Speedscope do expect(headers).to eq({ 'Content-Type' => 'text/html' }) expect(body.first).to include('speedscope-iframe') end + + context 'when the stackprof_mode parameter is set and valid' do + let(:env) { Rack::MockRequest.env_for('/', params: { 'performance_bar' => 'flamegraph', 'stackprof_mode' => 'cpu' }) } + + it 'runs StackProf in the mode specified in the stackprof_mode parameter' do + expect(StackProf).to receive(:run).with(hash_including(mode: :cpu)) + + middleware.call(env) + end + end + + context 'when the stackprof_mode parameter is not set' do + let(:env) { Rack::MockRequest.env_for('/', params: { 'performance_bar' => 'flamegraph' }) } + + it 'runs StackProf in wall mode' do + expect(StackProf).to receive(:run).with(hash_including(mode: :wall)) + + middleware.call(env) + end + end + + context 'when the stackprof_mode parameter is invalid' do + let(:env) { Rack::MockRequest.env_for('/', params: { 'performance_bar' => 'flamegraph', 'stackprof_mode' => 'invalid' }) } + + it 'runs StackProf in wall mode' do + expect(StackProf).to receive(:run).with(hash_including(mode: :wall)) + + middleware.call(env) + end + end + + context 'when the stackprof_mode parameter is set to object mode' do + let(:env) { Rack::MockRequest.env_for('/', params: { 'performance_bar' => 'flamegraph', 'stackprof_mode' => 'object' }) } + + it 'runs StackProf with an interval of 100' do + expect(StackProf).to receive(:run).with(hash_including(interval: 100)) + + middleware.call(env) + end + end + + context 'when the stackprof_mode parameter is not set to object mode' do + let(:env) { Rack::MockRequest.env_for('/', params: { 'performance_bar' => 'flamegraph', 'stackprof_mode' => 'wall' }) } + + it 'runs StackProf with an interval of 10_100' do + expect(StackProf).to receive(:run).with(hash_including(interval: 10_100)) + + middleware.call(env) + end + end end end end diff --git a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb index 4ce51e37685..00beacd4b35 100644 --- a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb @@ -41,14 +41,40 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder ) end - it 'returns records in correct order' do + let(:all_records) do all_records = [] iterator.each_batch(of: batch_size) do |records| all_records.concat(records) end + all_records + end + it 'returns records in correct order' do expect(all_records).to eq(expected_order) end + + context 'when not passing the finder query' do + before do + in_operator_optimization_options.delete(:finder_query) + end + + it 'returns records in correct order' do + expect(all_records).to eq(expected_order) + end + + it 'loads only the order by column' do + order_by_attribute_names = iterator + .send(:order) + .column_definitions + .map(&:attribute_name) + .map(&:to_s) + + record = all_records.first + loaded_attributes = record.attributes.keys - ['time_estimate'] # time_estimate is always present (has default value) + + expect(loaded_attributes).to eq(order_by_attribute_names) + end + end end context 'when ordering by issues.id DESC' do diff --git a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/strategies/order_values_loader_strategy_spec.rb b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/strategies/order_values_loader_strategy_spec.rb new file mode 100644 index 00000000000..fe95d5406dd --- /dev/null +++ b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/strategies/order_values_loader_strategy_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::Strategies::OrderValuesLoaderStrategy do + let(:model) { Project } + + let(:keyset_scope) do + scope, _ = Gitlab::Pagination::Keyset::SimpleOrderBuilder.build( + Project.order(:created_at, :id) + ) + + scope + end + + let(:keyset_order) do + Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(keyset_scope) + end + + let(:order_by_columns) do + Gitlab::Pagination::Keyset::InOperatorOptimization::OrderByColumns.new(keyset_order.column_definitions, model.arel_table) + end + + subject(:strategy) { described_class.new(model, order_by_columns) } + + describe '#initializer_columns' do + it 'returns NULLs for each ORDER BY columns' do + expect(strategy.initializer_columns).to eq([ + 'NULL::timestamp without time zone AS created_at', + 'NULL::integer AS id' + ]) + end + end +end diff --git a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/strategies/record_loader_strategy_spec.rb b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/strategies/record_loader_strategy_spec.rb new file mode 100644 index 00000000000..5180403b493 --- /dev/null +++ b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/strategies/record_loader_strategy_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::Strategies::RecordLoaderStrategy do + let(:finder_query) { -> (created_at_value, id_value) { Project.where(Project.arel_table[:id].eq(id_value)) } } + let(:model) { Project } + + let(:keyset_scope) do + scope, _ = Gitlab::Pagination::Keyset::SimpleOrderBuilder.build( + Project.order(:created_at, :id) + ) + + scope + end + + let(:keyset_order) do + Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(keyset_scope) + end + + let(:order_by_columns) do + Gitlab::Pagination::Keyset::InOperatorOptimization::OrderByColumns.new(keyset_order.column_definitions, model.arel_table) + end + + subject(:strategy) { described_class.new(finder_query, model, order_by_columns) } + + describe '#initializer_columns' do + # Explanation: + # > SELECT NULL::projects AS records + # + # The query returns one row and one column. The column may contain a full project row. + # In this particular case the row is NULL. + it 'returns a NULL table row as the result column' do + expect(strategy.initializer_columns).to eq(["NULL::projects AS records"]) + end + end + + describe '#columns' do + # Explanation: + # > SELECT (SELECT projects FROM projects limit 1) + # + # Selects one row from the database and collapses it into one column. + # + # Side note: Due to the type casts, columns and initializer_columns can be also UNION-ed: + # SELECT * FROM ( + # ( + # SELECT NULL::projects AS records + # UNION + # SELECT (SELECT projects FROM projects limit 1) + # ) + # ) as records + it 'uses the finder query to load the row in the result column' do + expected_loader_query = <<~SQL + (SELECT projects FROM "projects" WHERE "projects"."id" = recursive_keyset_cte.projects_id_array[position] LIMIT 1) + SQL + + expect(strategy.columns).to eq([expected_loader_query.chomp]) + end + end +end diff --git a/spec/lib/gitlab/pagination/keyset/iterator_spec.rb b/spec/lib/gitlab/pagination/keyset/iterator_spec.rb index d8e79287745..09cbca2c1cb 100644 --- a/spec/lib/gitlab/pagination/keyset/iterator_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/iterator_spec.rb @@ -32,8 +32,11 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do ]) end + let(:iterator_params) { nil } let(:scope) { project.issues.reorder(custom_reorder) } + subject(:iterator) { described_class.new(**iterator_params) } + shared_examples 'iterator examples' do describe '.each_batch' do it 'yields an ActiveRecord::Relation when a block is given' do @@ -56,6 +59,29 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do expect(count).to eq(9) end + it 'continues after the cursor' do + loaded_records = [] + cursor = nil + + # stopping the iterator after the first batch and storing the cursor + iterator.each_batch(of: 2) do |relation| # rubocop: disable Lint/UnreachableLoop + loaded_records.concat(relation.to_a) + record = loaded_records.last + + cursor = custom_reorder.cursor_attributes_for_node(record) + break + end + + expect(loaded_records).to eq(project.issues.order(custom_reorder).take(2)) + + new_iterator = described_class.new(**iterator_params.merge(cursor: cursor)) + new_iterator.each_batch(of: 2) do |relation| + loaded_records.concat(relation.to_a) + end + + expect(loaded_records).to eq(project.issues.order(custom_reorder)) + end + it 'allows updating of the yielded relations' do time = Time.current @@ -73,7 +99,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) } - expect(positions).to eq(project.issues.order_relative_position_asc.order(id: :asc).pluck(:relative_position, :id)) + expect(positions).to eq(project.issues.reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')).order(id: :asc).pluck(:relative_position, :id)) end end @@ -85,7 +111,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) } - expect(positions).to eq(project.issues.order_relative_position_desc.order(id: :desc).pluck(:relative_position, :id)) + expect(positions).to eq(project.issues.reorder(::Gitlab::Database.nulls_first_order('relative_position', 'DESC')).order(id: :desc).pluck(:relative_position, :id)) end end @@ -131,13 +157,13 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do end context 'when use_union_optimization is used' do - subject(:iterator) { described_class.new(scope: scope, use_union_optimization: true) } + let(:iterator_params) { { scope: scope, use_union_optimization: true } } include_examples 'iterator examples' end context 'when use_union_optimization is not used' do - subject(:iterator) { described_class.new(scope: scope, use_union_optimization: false) } + let(:iterator_params) { { scope: scope, use_union_optimization: false } } include_examples 'iterator examples' end diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb index aa13660deb4..2f38ed58727 100644 --- a/spec/lib/gitlab/path_regex_spec.rb +++ b/spec/lib/gitlab/path_regex_spec.rb @@ -561,4 +561,25 @@ RSpec.describe Gitlab::PathRegex do expect(subject.match('sha256:asdf1234%2f')[0]).to eq('sha256:asdf1234') end end + + describe '.dependency_proxy_route_regex' do + subject { described_class.dependency_proxy_route_regex } + + it { is_expected.to match('/v2/group1/dependency_proxy/containers/alpine/manifests/latest') } + it { is_expected.to match('/v2/group1/dependency_proxy/containers/alpine/blobs/sha256:14119a10abf4669e8cdbdff324a9f9605d99697215a0d21c360fe8dfa8471bab') } + + it { is_expected.not_to match('') } + it { is_expected.not_to match('/v3/group1/dependency_proxy/containers/alpine/manifests/latest') } + it { is_expected.not_to match('/v2/group1/dependency_proxy/container/alpine/manifests/latest') } + it { is_expected.not_to match('/v2/group1/dependency_prox/containers/alpine/manifests/latest') } + it { is_expected.not_to match('/v2/group1/dependency_proxy/containers/alpine/manifest/latest') } + it { is_expected.not_to match('/v2/group1/dependency_proxy/containers/alpine/manifest/la%2Ftest') } + it { is_expected.not_to match('/v2/group1/dependency_proxy/containers/alpine/manifest/latest/../one') } + it { is_expected.not_to match('/v3/group1/dependency_proxy/containers/alpine/blobs/sha256:14119a10abf4669e8cdbdff324a9f9605d99697215a0d21c360fe8dfa8471bab') } + it { is_expected.not_to match('/v2/group1/dependency_proxy/container/alpine/blobs/sha256:14119a10abf4669e8cdbdff324a9f9605d99697215a0d21c360fe8dfa8471bab') } + it { is_expected.not_to match('/v2/group1/dependency_prox/containers/alpine/blobs/sha256:14119a10abf4669e8cdbdff324a9f9605d99697215a0d21c360fe8dfa8471bab') } + it { is_expected.not_to match('/v2/group1/dependency_proxy/containers/alpine/blob/sha256:14119a10abf4669e8cdbdff324a9f9605d99697215a0d21c360fe8dfa8471bab') } + it { is_expected.not_to match('/v2/group1/dependency_proxy/containers/alpine/blob/sha256:F14119a10abf4669e8cdbdff324a9f9605d99697215a0d21c360fe8dfa8471bab/../latest') } + it { is_expected.not_to match('/v2/group1/dependency_proxy/containers/alpine/blob/sha256:F14119a10abf4669e8cdbdff324a9f9605d99697215a0d21c360fe8dfa8471bab/latest') } + end end diff --git a/spec/lib/gitlab/performance_bar/stats_spec.rb b/spec/lib/gitlab/performance_bar/stats_spec.rb index ad11eca56d1..b4f90745ee7 100644 --- a/spec/lib/gitlab/performance_bar/stats_spec.rb +++ b/spec/lib/gitlab/performance_bar/stats_spec.rb @@ -23,11 +23,23 @@ RSpec.describe Gitlab::PerformanceBar::Stats do expect(logger).to receive(:info) .with({ duration_ms: 1.096, filename: 'lib/gitlab/pagination/offset_pagination.rb', method_path: 'lib/gitlab/pagination/offset_pagination.rb:add_pagination_headers', - count: 1, request_id: 'foo', type: :sql }) + count: 1, request_id: 'foo', query_type: 'active-record' }) expect(logger).to receive(:info) .with({ duration_ms: 1.634, filename: 'lib/api/helpers.rb', method_path: 'lib/api/helpers.rb:find_project', - count: 2, request_id: 'foo', type: :sql }) + count: 2, request_id: 'foo', query_type: 'active-record' }) + expect(logger).to receive(:info) + .with({ duration_ms: 23.709, filename: 'lib/gitlab/gitaly_client/commit_service.rb', + method_path: 'lib/gitlab/gitaly_client/commit_service.rb:each', + count: 1, request_id: 'foo', query_type: 'gitaly' }) + expect(logger).to receive(:info) + .with({ duration_ms: 6.678, filename: 'lib/gitlab/gitaly_client/commit_service.rb', + method_path: 'lib/gitlab/gitaly_client/commit_service.rb:call_find_commit', + count: 1, request_id: 'foo', query_type: 'gitaly' }) + expect(logger).to receive(:info) + .with({ duration_ms: 0.155, filename: 'lib/feature.rb', + method_path: 'lib/feature.rb:enabled?', + count: 1, request_id: 'foo', query_type: 'redis' }) subject end diff --git a/spec/lib/gitlab/project_authorizations_spec.rb b/spec/lib/gitlab/project_authorizations_spec.rb index d2b41ee31d9..16066934194 100644 --- a/spec/lib/gitlab/project_authorizations_spec.rb +++ b/spec/lib/gitlab/project_authorizations_spec.rb @@ -204,6 +204,43 @@ RSpec.describe Gitlab::ProjectAuthorizations do end end + context 'with shared projects' do + let_it_be(:shared_with_group) { create(:group) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, group: create(:group)) } + + let(:mapping) { map_access_levels(authorizations) } + + before do + create(:project_group_link, :developer, project: project, group: shared_with_group) + shared_with_group.add_maintainer(user) + end + + it 'creates proper authorizations' do + expect(mapping[project.id]).to eq(Gitlab::Access::DEVELOPER) + end + + context 'even when the `lock_memberships_to_ldap` setting has been turned ON' do + before do + stub_application_setting(lock_memberships_to_ldap: true) + end + + it 'creates proper authorizations' do + expect(mapping[project.id]).to eq(Gitlab::Access::DEVELOPER) + end + end + + context 'when the group containing the project has forbidden group shares for any of its projects' do + before do + project.namespace.update!(share_with_group_lock: true) + end + + it 'does not create authorizations' do + expect(mapping[project.id]).to be_nil + end + end + end + context 'with shared groups' do let(:parent_group_user) { create(:user) } let(:group_user) { create(:user) } diff --git a/spec/lib/gitlab/rack_attack/request_spec.rb b/spec/lib/gitlab/rack_attack/request_spec.rb index 3be7ec17e45..ecdcc23e588 100644 --- a/spec/lib/gitlab/rack_attack/request_spec.rb +++ b/spec/lib/gitlab/rack_attack/request_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe Gitlab::RackAttack::Request do + using RSpec::Parameterized::TableSyntax + describe 'FILES_PATH_REGEX' do subject { described_class::FILES_PATH_REGEX } @@ -13,4 +15,33 @@ RSpec.describe Gitlab::RackAttack::Request do it { is_expected.to match('/api/v4/projects/some%2Fnested%2Frepo/repository/files/README') } it { is_expected.not_to match('/api/v4/projects/some/nested/repo/repository/files/README') } end + + describe '#deprecated_api_request?' do + let(:env) { { 'REQUEST_METHOD' => 'GET', 'rack.input' => StringIO.new, 'PATH_INFO' => path, 'QUERY_STRING' => query } } + let(:request) { ::Rack::Attack::Request.new(env) } + + subject { !!request.__send__(:deprecated_api_request?) } + + where(:path, :query, :expected) do + '/' | '' | false + + '/api/v4/groups/1/' | '' | true + '/api/v4/groups/1' | '' | true + '/api/v4/groups/foo/' | '' | true + '/api/v4/groups/foo' | '' | true + + '/api/v4/groups/1' | 'with_projects=' | true + '/api/v4/groups/1' | 'with_projects=1' | true + '/api/v4/groups/1' | 'with_projects=0' | false + + '/foo/api/v4/groups/1' | '' | false + '/api/v4/groups/1/foo' | '' | false + + '/api/v4/groups/nested%2Fgroup' | '' | true + end + + with_them do + it { is_expected.to eq(expected) } + end + end end diff --git a/spec/lib/gitlab/rate_limit_helpers_spec.rb b/spec/lib/gitlab/rate_limit_helpers_spec.rb index d583c8e58fb..ad0e2de1448 100644 --- a/spec/lib/gitlab/rate_limit_helpers_spec.rb +++ b/spec/lib/gitlab/rate_limit_helpers_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::RateLimitHelpers, :clean_gitlab_redis_cache do +RSpec.describe Gitlab::RateLimitHelpers, :clean_gitlab_redis_rate_limiting do let(:limiter_class) do Class.new do include ::Gitlab::RateLimitHelpers diff --git a/spec/lib/gitlab/redis/queues_spec.rb b/spec/lib/gitlab/redis/queues_spec.rb index 2e396cde3bf..a0f73a654e7 100644 --- a/spec/lib/gitlab/redis/queues_spec.rb +++ b/spec/lib/gitlab/redis/queues_spec.rb @@ -9,10 +9,24 @@ RSpec.describe Gitlab::Redis::Queues do include_examples "redis_shared_examples" describe '#raw_config_hash' do - it 'has a legacy default URL' do - expect(subject).to receive(:fetch_config) { false } + before do + expect(subject).to receive(:fetch_config) { config } + end + + context 'when the config url is blank' do + let(:config) { nil } + + it 'has a legacy default URL' do + expect(subject.send(:raw_config_hash)).to eq(url: 'redis://localhost:6381' ) + end + end + + context 'when the config url is present' do + let(:config) { { url: 'redis://localhost:1111' } } - expect(subject.send(:raw_config_hash)).to eq(url: 'redis://localhost:6381' ) + it 'sets the configured url' do + expect(subject.send(:raw_config_hash)).to eq(url: 'redis://localhost:1111' ) + end end end end diff --git a/spec/lib/gitlab/redis/rate_limiting_spec.rb b/spec/lib/gitlab/redis/rate_limiting_spec.rb new file mode 100644 index 00000000000..e79c070df93 --- /dev/null +++ b/spec/lib/gitlab/redis/rate_limiting_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Redis::RateLimiting do + include_examples "redis_new_instance_shared_examples", 'rate_limiting', Gitlab::Redis::Cache +end diff --git a/spec/lib/gitlab/redis/sessions_spec.rb b/spec/lib/gitlab/redis/sessions_spec.rb new file mode 100644 index 00000000000..7e239c08e9f --- /dev/null +++ b/spec/lib/gitlab/redis/sessions_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Redis::Sessions do + include_examples "redis_new_instance_shared_examples", 'sessions', Gitlab::Redis::SharedState +end diff --git a/spec/lib/gitlab/redis/trace_chunks_spec.rb b/spec/lib/gitlab/redis/trace_chunks_spec.rb index e974dc519d6..bb3c3089430 100644 --- a/spec/lib/gitlab/redis/trace_chunks_spec.rb +++ b/spec/lib/gitlab/redis/trace_chunks_spec.rb @@ -3,53 +3,5 @@ require 'spec_helper' RSpec.describe Gitlab::Redis::TraceChunks do - let(:instance_specific_config_file) { "config/redis.trace_chunks.yml" } - let(:environment_config_file_name) { "GITLAB_REDIS_TRACE_CHUNKS_CONFIG_FILE" } - let(:shared_state_config_file) { nil } - - before do - allow(Gitlab::Redis::SharedState).to receive(:config_file_name).and_return(shared_state_config_file) - end - - include_examples "redis_shared_examples" - - describe '.config_file_name' do - subject { described_class.config_file_name } - - let(:rails_root) { Dir.mktmpdir('redis_shared_examples') } - - before do - # Undo top-level stub of config_file_name because we are testing that method now. - allow(described_class).to receive(:config_file_name).and_call_original - - allow(described_class).to receive(:rails_root).and_return(rails_root) - FileUtils.mkdir_p(File.join(rails_root, 'config')) - end - - after do - FileUtils.rm_rf(rails_root) - end - - context 'when there is only a resque.yml' do - before do - FileUtils.touch(File.join(rails_root, 'config/resque.yml')) - end - - it { expect(subject).to eq("#{rails_root}/config/resque.yml") } - - context 'and there is a global env override' do - before do - stub_env('GITLAB_REDIS_CONFIG_FILE', 'global override') - end - - it { expect(subject).to eq('global override') } - - context 'and SharedState has a different config file' do - let(:shared_state_config_file) { 'shared state config file' } - - it { expect(subject).to eq('shared state config file') } - end - end - end - end + include_examples "redis_new_instance_shared_examples", 'trace_chunks', Gitlab::Redis::SharedState end diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb index f1b4e50b1eb..9514654204b 100644 --- a/spec/lib/gitlab/regex_spec.rb +++ b/spec/lib/gitlab/regex_spec.rb @@ -12,22 +12,29 @@ RSpec.describe Gitlab::Regex do it { is_expected.to match('Dash – is this') } end - shared_examples_for 'project/group name regex' do + shared_examples_for 'group name regex' do it_behaves_like 'project/group name chars regex' it { is_expected.not_to match('?gitlab') } it { is_expected.not_to match("Users's something") } end + shared_examples_for 'project name regex' do + it_behaves_like 'project/group name chars regex' + it { is_expected.to match("Gitlab++") } + it { is_expected.not_to match('?gitlab') } + it { is_expected.not_to match("Users's something") } + end + describe '.project_name_regex' do subject { described_class.project_name_regex } - it_behaves_like 'project/group name regex' + it_behaves_like 'project name regex' end describe '.group_name_regex' do subject { described_class.group_name_regex } - it_behaves_like 'project/group name regex' + it_behaves_like 'group name regex' it 'allows parenthesis' do is_expected.to match('Group One (Test)') @@ -51,7 +58,7 @@ RSpec.describe Gitlab::Regex do describe '.project_name_regex_message' do subject { described_class.project_name_regex_message } - it { is_expected.to eq("can contain only letters, digits, emojis, '_', '.', dash, space. It must start with letter, digit, emoji or '_'.") } + it { is_expected.to eq("can contain only letters, digits, emojis, '_', '.', '+', dashes, or spaces. It must start with a letter, digit, emoji, or '_'.") } end describe '.group_name_regex_message' do @@ -646,13 +653,24 @@ RSpec.describe Gitlab::Regex do it { is_expected.to match('release') } it { is_expected.to match('my-repo') } - it { is_expected.to match('my-repo42') } + it { is_expected.to match('My-Re_po') } + it { is_expected.to match('my_repo42') } + it { is_expected.to match('1.2.3') } + it { is_expected.to match('v1.2.3-beta-12') } + it { is_expected.to match('renovate_https-github.com-operator-framework-operator-lifecycle-manager.git-0.x') } # Do not allow empty it { is_expected.not_to match('') } # Do not allow Unicode it { is_expected.not_to match('hé') } + + it { is_expected.not_to match('.1.23') } + it { is_expected.not_to match('1..23') } + it { is_expected.not_to match('1.2.3.') } + it { is_expected.not_to match('1..2.3.') } + it { is_expected.not_to match('1/../2.3.') } + it { is_expected.not_to match('1/..%2F2.3.') } end describe '.helm_package_regex' do diff --git a/spec/lib/gitlab/request_endpoints_spec.rb b/spec/lib/gitlab/request_endpoints_spec.rb new file mode 100644 index 00000000000..0c939bfb0ee --- /dev/null +++ b/spec/lib/gitlab/request_endpoints_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Gitlab::RequestEndpoints do + describe '.all_api_endpoints' do + it 'selects all feature API classes' do + api_classes = described_class.all_api_endpoints.map { |route| route.app.options[:for] } + + expect(api_classes).to all(include(Gitlab::EndpointAttributes)) + end + end + + describe '.all_controller_actions' do + it 'selects all feature controllers and action names' do + all_controller_actions = described_class.all_controller_actions + controller_classes = all_controller_actions.map(&:first) + all_actions = all_controller_actions.map(&:last) + + expect(controller_classes).to all(include(Gitlab::EndpointAttributes)) + expect(controller_classes).not_to include(ApplicationController, Devise::UnlocksController) + expect(all_actions).to all(be_a(String)) + end + end +end diff --git a/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb index a98038cd3f8..d801b84775b 100644 --- a/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb +++ b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb @@ -18,7 +18,7 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do end it 'logs start and end of job' do - Timecop.freeze(timestamp) do + travel_to(timestamp) do expect(logger).to receive(:info).with(start_payload).ordered expect(logger).to receive(:info).with(end_payload).ordered expect(subject).to receive(:log_job_start).and_call_original @@ -34,7 +34,7 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do "wrapped" => "TestWorker" ) - Timecop.freeze(timestamp) do + travel_to(timestamp) do expect(logger).to receive(:info).with(start_payload).ordered expect(logger).to receive(:info).with(end_payload).ordered expect(subject).to receive(:log_job_start).and_call_original @@ -45,7 +45,7 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do end it 'logs an exception in job' do - Timecop.freeze(timestamp) do + travel_to(timestamp) do expect(logger).to receive(:info).with(start_payload) expect(logger).to receive(:warn).with(include(exception_payload)) expect(subject).to receive(:log_job_start).and_call_original @@ -60,7 +60,7 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do end it 'logs the root cause of an Sidekiq::JobRetry::Skip exception in the job' do - Timecop.freeze(timestamp) do + travel_to(timestamp) do expect(logger).to receive(:info).with(start_payload) expect(logger).to receive(:warn).with(include(exception_payload)) expect(subject).to receive(:log_job_start).and_call_original @@ -77,7 +77,7 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do end it 'logs the root cause of an Sidekiq::JobRetry::Handled exception in the job' do - Timecop.freeze(timestamp) do + travel_to(timestamp) do expect(logger).to receive(:info).with(start_payload) expect(logger).to receive(:warn).with(include(exception_payload)) expect(subject).to receive(:log_job_start).and_call_original @@ -94,7 +94,7 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do end it 'keeps Sidekiq::JobRetry::Handled exception if the cause does not exist' do - Timecop.freeze(timestamp) do + travel_to(timestamp) do expect(logger).to receive(:info).with(start_payload) expect(logger).to receive(:warn).with( include( @@ -116,7 +116,7 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do end it 'does not modify the job' do - Timecop.freeze(timestamp) do + travel_to(timestamp) do job_copy = job.deep_dup allow(logger).to receive(:info) @@ -130,7 +130,7 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do end it 'does not modify the wrapped job' do - Timecop.freeze(timestamp) do + travel_to(timestamp) do wrapped_job = job.merge( "class" => "ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper", "wrapped" => "TestWorker" @@ -154,7 +154,7 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do end it 'logs start and end of job without args' do - Timecop.freeze(timestamp) do + travel_to(timestamp) do expect(logger).to receive(:info).with(start_payload.except('args')).ordered expect(logger).to receive(:info).with(end_payload.except('args')).ordered expect(subject).to receive(:log_job_start).and_call_original @@ -165,7 +165,7 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do end it 'logs without created_at and enqueued_at fields' do - Timecop.freeze(timestamp) do + travel_to(timestamp) do excluded_fields = %w(created_at enqueued_at args scheduling_latency_s) expect(logger).to receive(:info).with(start_payload.except(*excluded_fields)).ordered @@ -183,7 +183,7 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do let(:scheduling_latency_s) { 7200.0 } it 'logs with scheduling latency' do - Timecop.freeze(timestamp) do + travel_to(timestamp) do expect(logger).to receive(:info).with(start_payload).ordered expect(logger).to receive(:info).with(end_payload).ordered expect(subject).to receive(:log_job_start).and_call_original @@ -194,6 +194,35 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do end end + context 'with enqueue latency' do + let(:expected_start_payload) do + start_payload.merge( + 'scheduled_at' => job['scheduled_at'], + 'enqueue_latency_s' => 1.hour.to_f + ) + end + + let(:expected_end_payload) do + end_payload.merge('enqueue_latency_s' => 1.hour.to_f) + end + + before do + # enqueued_at is set to created_at + job['scheduled_at'] = created_at - 1.hour + end + + it 'logs with scheduling latency' do + travel_to(timestamp) do + expect(logger).to receive(:info).with(expected_start_payload).ordered + expect(logger).to receive(:info).with(expected_end_payload).ordered + expect(subject).to receive(:log_job_start).and_call_original + expect(subject).to receive(:log_job_done).and_call_original + + call_subject(job, 'test_queue') { } + end + end + end + context 'with Gitaly, Rugged, and Redis calls' do let(:timing_data) do { @@ -218,7 +247,7 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do end it 'logs with Gitaly and Rugged timing data', :aggregate_failures do - Timecop.freeze(timestamp) do + travel_to(timestamp) do expect(logger).to receive(:info).with(start_payload).ordered expect(logger).to receive(:info).with(expected_end_payload).ordered @@ -243,8 +272,22 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do expected_end_payload.merge( 'db_duration_s' => a_value >= 0.1, 'db_count' => a_value >= 1, - 'db_cached_count' => 0, - 'db_write_count' => 0 + "db_replica_#{db_config_name}_count" => 0, + 'db_replica_duration_s' => a_value >= 0, + 'db_primary_count' => a_value >= 1, + "db_primary_#{db_config_name}_count" => a_value >= 1, + 'db_primary_duration_s' => a_value > 0, + "db_primary_#{db_config_name}_duration_s" => a_value > 0 + ) + end + + let(:end_payload) do + start_payload.merge(db_payload_defaults).merge( + 'message' => 'TestWorker JID-da883554ee4fe414012f5f42: done: 0.0 sec', + 'job_status' => 'done', + 'duration_s' => 0.0, + 'completed_at' => timestamp.to_f, + 'cpu_s' => 1.111112 ) end @@ -274,59 +317,9 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do end end - context 'when load balancing is disabled' do - before do - allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(false) - end - - let(:expected_end_payload_with_db) do - expected_end_payload.merge( - 'db_duration_s' => a_value >= 0.1, - 'db_count' => a_value >= 1, - 'db_cached_count' => 0, - 'db_write_count' => 0 - ) - end - - include_examples 'performs database queries' - end - - context 'when load balancing is enabled', :db_load_balancing do - let(:db_config_name) { ::Gitlab::Database.db_config_name(ApplicationRecord.connection) } - - let(:expected_db_payload_defaults) do - metrics = - ::Gitlab::Metrics::Subscribers::ActiveRecord.load_balancing_metric_counter_keys + - ::Gitlab::Metrics::Subscribers::ActiveRecord.load_balancing_metric_duration_keys + - ::Gitlab::Metrics::Subscribers::ActiveRecord.db_counter_keys + - [:db_duration_s] - - metrics.each_with_object({}) do |key, result| - result[key.to_s] = 0 - end - end - - let(:expected_end_payload_with_db) do - expected_end_payload.merge(expected_db_payload_defaults).merge( - 'db_duration_s' => a_value >= 0.1, - 'db_count' => a_value >= 1, - "db_replica_#{db_config_name}_count" => 0, - 'db_replica_duration_s' => a_value >= 0, - 'db_primary_count' => a_value >= 1, - "db_primary_#{db_config_name}_count" => a_value >= 1, - 'db_primary_duration_s' => a_value > 0, - "db_primary_#{db_config_name}_duration_s" => a_value > 0 - ) - end - - let(:end_payload) do - start_payload.merge(expected_db_payload_defaults).merge( - 'message' => 'TestWorker JID-da883554ee4fe414012f5f42: done: 0.0 sec', - 'job_status' => 'done', - 'duration_s' => 0.0, - 'completed_at' => timestamp.to_f, - 'cpu_s' => 1.111112 - ) + context 'when load balancing is enabled' do + let(:db_config_name) do + ::Gitlab::Database.db_config_name(ApplicationRecord.retrieve_connection) end include_examples 'performs database queries' @@ -359,7 +352,7 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do end it 'logs it in the done log' do - Timecop.freeze(timestamp) do + travel_to(timestamp) do expect(logger).to receive(:info).with(expected_start_payload).ordered expect(logger).to receive(:info).with(expected_end_payload).ordered @@ -401,7 +394,7 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do end it 'logs it in the done log' do - Timecop.freeze(timestamp) do + travel_to(timestamp) do expect(logger).to receive(:info).with(expected_start_payload).ordered expect(logger).to receive(:info).with(expected_end_payload).ordered @@ -426,13 +419,13 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do 'message' => 'my-message', 'job_status' => 'my-job-status', 'duration_s' => 0.123123, - 'completed_at' => current_utc_time.to_f } + 'completed_at' => current_utc_time.to_i } end subject { described_class.new } it 'update payload correctly' do - Timecop.freeze(current_utc_time) do + travel_to(current_utc_time) do subject.send(:add_time_keys!, time, payload) expect(payload).to eq(payload_with_time_keys) diff --git a/spec/lib/gitlab/sidekiq_middleware/client_metrics_spec.rb b/spec/lib/gitlab/sidekiq_middleware/client_metrics_spec.rb index 698758a13fd..dca00c85e30 100644 --- a/spec/lib/gitlab/sidekiq_middleware/client_metrics_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/client_metrics_spec.rb @@ -62,6 +62,27 @@ RSpec.describe Gitlab::SidekiqMiddleware::ClientMetrics do Sidekiq::Testing.inline! { TestWorker.perform_in(1.second) } end + + it 'sets the scheduled_at field' do + job = { 'at' => Time.current } + + subject.call('TestWorker', job, 'queue', nil) do + expect(job[:scheduled_at]).to eq(job['at']) + end + end + end + + context 'when the worker class cannot be found' do + it 'increments enqueued jobs metric with the worker labels set to NilClass' do + test_anonymous_worker = Class.new(TestWorker) + + expect(enqueued_jobs_metric).to receive(:increment).with(a_hash_including(worker: 'NilClass'), 1) + + # Sidekiq won't be able to create an instance of this class + expect do + Sidekiq::Testing.inline! { test_anonymous_worker.perform_async } + end.to raise_error(NameError) + end end end end diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb index cc69a11f7f8..5083ac514db 100644 --- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb @@ -472,6 +472,26 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi expect(duplicate_job).to be_idempotent end end + + context 'when worker class is utilizing load balancing capabilities' do + before do + allow(AuthorizedProjectsWorker).to receive(:utilizes_load_balancing_capabilities?).and_return(true) + end + + it 'returns true' do + expect(duplicate_job).to be_idempotent + end + + context 'when preserve_latest_wal_locations_for_idempotent_jobs feature flag is disabled' do + before do + stub_feature_flags(preserve_latest_wal_locations_for_idempotent_jobs: false) + end + + it 'returns false' do + expect(duplicate_job).not_to be_idempotent + end + end + end end def existing_wal_location_key(idempotency_key, config_name) diff --git a/spec/lib/gitlab/sidekiq_middleware/memory_killer_spec.rb b/spec/lib/gitlab/sidekiq_middleware/memory_killer_spec.rb index 0d1616c4aed..1667622ad8e 100644 --- a/spec/lib/gitlab/sidekiq_middleware/memory_killer_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/memory_killer_spec.rb @@ -45,7 +45,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::MemoryKiller do expect(subject).to receive(:sleep).with(30).ordered expect(Process).to receive(:kill).with('SIGTERM', pid).ordered - expect(subject).to receive(:sleep).with(10).ordered + expect(subject).to receive(:sleep).with(Sidekiq.options[:timeout] + 2).ordered expect(Process).to receive(:kill).with('SIGKILL', pid).ordered expect(Sidekiq.logger) diff --git a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb index cae0bb6b167..914f5a30c3a 100644 --- a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb @@ -211,6 +211,9 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do end end + include_context 'server metrics with mocked prometheus' + include_context 'server metrics call' + before do stub_const('TestWorker', Class.new) TestWorker.class_eval do @@ -234,9 +237,6 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do end end - include_context 'server metrics with mocked prometheus' - include_context 'server metrics call' - shared_context 'worker declaring data consistency' do let(:worker_class) { LBTestWorker } @@ -250,61 +250,93 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do end end - context 'when load_balancing is enabled' do - before do - allow(::Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(true) - end + describe '#call' do + context 'when worker declares data consistency' do + include_context 'worker declaring data consistency' - describe '#call' do - context 'when worker declares data consistency' do - include_context 'worker declaring data consistency' + it 'increments load balancing counter with defined data consistency' do + process_job + + expect(load_balancing_metric).to have_received(:increment).with( + a_hash_including( + data_consistency: :delayed, + load_balancing_strategy: 'replica' + ), 1) + end + end - it 'increments load balancing counter with defined data consistency' do - process_job + context 'when worker does not declare data consistency' do + it 'increments load balancing counter with default data consistency' do + process_job - expect(load_balancing_metric).to have_received(:increment).with( - a_hash_including( - data_consistency: :delayed, - load_balancing_strategy: 'replica' - ), 1) - end + expect(load_balancing_metric).to have_received(:increment).with( + a_hash_including( + data_consistency: :always, + load_balancing_strategy: 'primary' + ), 1) end + end + end + end - context 'when worker does not declare data consistency' do - it 'increments load balancing counter with default data consistency' do - process_job + context 'feature attribution' do + let(:test_worker) do + category = worker_category - expect(load_balancing_metric).to have_received(:increment).with( - a_hash_including( - data_consistency: :always, - load_balancing_strategy: 'primary' - ), 1) - end + Class.new do + include Sidekiq::Worker + include WorkerAttributes + + if category + feature_category category + else + feature_category_not_owned! + end + + def perform end end end - context 'when load_balancing is disabled' do - include_context 'worker declaring data consistency' + let(:context_category) { 'continuous_integration' } + let(:job) { { 'meta.feature_category' => 'continuous_integration' } } - before do - allow(::Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(false) + before do + stub_const('TestWorker', test_worker) + end + + around do |example| + with_sidekiq_server_middleware do |chain| + Gitlab::SidekiqMiddleware.server_configurator( + metrics: true, + arguments_logger: false, + memory_killer: false + ).call(chain) + + Sidekiq::Testing.inline! { example.run } end + end - describe '#initialize' do - it 'does not set load_balancing metrics' do - expect(Gitlab::Metrics).not_to receive(:counter).with(:sidekiq_load_balancing_count, anything) + include_context 'server metrics with mocked prometheus' + include_context 'server metrics call' - subject - end + context 'when a worker has a feature category' do + let(:worker_category) { 'authentication_and_authorization' } + + it 'uses that category for metrics' do + expect(completion_seconds_metric).to receive(:observe).with(a_hash_including(feature_category: worker_category), anything) + + TestWorker.process_job(job) end + end - describe '#call' do - it 'does not increment load balancing counter' do - process_job + context 'when a worker does not have a feature category' do + let(:worker_category) { nil } - expect(load_balancing_metric).not_to have_received(:increment) - end + it 'uses the category from the context for metrics' do + expect(completion_seconds_metric).to receive(:observe).with(a_hash_including(feature_category: context_category), anything) + + TestWorker.process_job(job) end end end diff --git a/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb b/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb index d6cc787f53d..92a11c83a4a 100644 --- a/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb @@ -22,8 +22,31 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Client do end end + let(:not_owned_worker_class) do + Class.new(worker_class) do + def self.name + 'TestNotOwnedWithContextWorker' + end + + feature_category_not_owned! + end + end + + let(:mailer_class) do + Class.new(ApplicationMailer) do + def self.name + 'TestMailer' + end + + def test_mail + end + end + end + before do - stub_const('TestWithContextWorker', worker_class) + stub_const(worker_class.name, worker_class) + stub_const(not_owned_worker_class.name, not_owned_worker_class) + stub_const(mailer_class.name, mailer_class) end describe "#call" do @@ -58,6 +81,26 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Client do expect(job1['meta.feature_category']).to eq('issue_tracking') expect(job2['meta.feature_category']).to eq('issue_tracking') end + + it 'takes the feature category from the caller if the worker is not owned' do + TestNotOwnedWithContextWorker.bulk_perform_async_with_contexts( + %w(job1 job2), + arguments_proc: -> (name) { [name, 1, 2, 3] }, + context_proc: -> (_) { { feature_category: 'code_review' } } + ) + + job1 = TestNotOwnedWithContextWorker.job_for_args(['job1', 1, 2, 3]) + job2 = TestNotOwnedWithContextWorker.job_for_args(['job2', 1, 2, 3]) + + expect(job1['meta.feature_category']).to eq('code_review') + expect(job2['meta.feature_category']).to eq('code_review') + end + + it 'does not set any explicit feature category for mailers', :sidekiq_mailers do + expect(Gitlab::ApplicationContext).not_to receive(:with_context) + + TestMailer.test_mail.deliver_later + end end context 'when the feature category is already set in the surrounding block' do @@ -76,6 +119,22 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Client do expect(job1['meta.feature_category']).to eq('issue_tracking') expect(job2['meta.feature_category']).to eq('issue_tracking') end + + it 'takes the feature category from the caller if the worker is not owned' do + Gitlab::ApplicationContext.with_context(feature_category: 'authentication_and_authorization') do + TestNotOwnedWithContextWorker.bulk_perform_async_with_contexts( + %w(job1 job2), + arguments_proc: -> (name) { [name, 1, 2, 3] }, + context_proc: -> (_) { {} } + ) + end + + job1 = TestNotOwnedWithContextWorker.job_for_args(['job1', 1, 2, 3]) + job2 = TestNotOwnedWithContextWorker.job_for_args(['job2', 1, 2, 3]) + + expect(job1['meta.feature_category']).to eq('authentication_and_authorization') + expect(job2['meta.feature_category']).to eq('authentication_and_authorization') + end end end end diff --git a/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb b/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb index f736a7db774..377ff6fd166 100644 --- a/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Server do - let(:worker_class) do + let(:test_worker) do Class.new do def self.name "TestWorker" @@ -23,6 +23,16 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Server do end end + let(:not_owned_worker) do + Class.new(test_worker) do + def self.name + "NotOwnedWorker" + end + + feature_category_not_owned! + end + end + let(:other_worker) do Class.new do def self.name @@ -37,7 +47,8 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Server do end before do - stub_const("TestWorker", worker_class) + stub_const("TestWorker", test_worker) + stub_const("NotOwnedWorker", not_owned_worker) stub_const("OtherWorker", other_worker) end @@ -57,10 +68,24 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Server do expect(TestWorker.contexts['identifier'].keys).not_to include('meta.user') end - it 'takes the feature category from the worker' do - TestWorker.perform_async('identifier', 1) + context 'feature category' do + it 'takes the feature category from the worker' do + Gitlab::ApplicationContext.with_context(feature_category: 'authentication_and_authorization') do + TestWorker.perform_async('identifier', 1) + end + + expect(TestWorker.contexts['identifier']).to include('meta.feature_category' => 'foo') + end - expect(TestWorker.contexts['identifier']).to include('meta.feature_category' => 'foo') + context 'when the worker is not owned' do + it 'takes the feature category from the surrounding context' do + Gitlab::ApplicationContext.with_context(feature_category: 'authentication_and_authorization') do + NotOwnedWorker.perform_async('identifier', 1) + end + + expect(NotOwnedWorker.contexts['identifier']).to include('meta.feature_category' => 'authentication_and_authorization') + end + end end it "doesn't fail for unknown workers" do diff --git a/spec/lib/gitlab/sidekiq_middleware_spec.rb b/spec/lib/gitlab/sidekiq_middleware_spec.rb index 8285cf960d2..e687c8e8cf7 100644 --- a/spec/lib/gitlab/sidekiq_middleware_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware_spec.rb @@ -28,9 +28,8 @@ RSpec.describe Gitlab::SidekiqMiddleware do stub_const('TestWorker', worker_class) end - shared_examples "a middleware chain" do |load_balancing_enabled| + shared_examples "a middleware chain" do before do - allow(::Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(load_balancing_enabled) configurator.call(chain) end @@ -45,10 +44,10 @@ RSpec.describe Gitlab::SidekiqMiddleware do end end - shared_examples "a middleware chain for mailer" do |load_balancing_enabled| + shared_examples "a middleware chain for mailer" do let(:worker_class) { ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper } - it_behaves_like "a middleware chain", load_balancing_enabled + it_behaves_like "a middleware chain" end describe '.server_configurator' do @@ -58,13 +57,13 @@ RSpec.describe Gitlab::SidekiqMiddleware do let(:all_sidekiq_middlewares) do [ ::Gitlab::SidekiqMiddleware::Monitor, + ::Labkit::Middleware::Sidekiq::Server, ::Gitlab::SidekiqMiddleware::ServerMetrics, ::Gitlab::SidekiqMiddleware::ArgumentsLogger, ::Gitlab::SidekiqMiddleware::MemoryKiller, ::Gitlab::SidekiqMiddleware::RequestStoreMiddleware, ::Gitlab::SidekiqMiddleware::ExtraDoneLogMetadata, ::Gitlab::SidekiqMiddleware::BatchLoader, - ::Labkit::Middleware::Sidekiq::Server, ::Gitlab::SidekiqMiddleware::InstrumentationLogger, ::Gitlab::SidekiqMiddleware::AdminMode::Server, ::Gitlab::SidekiqVersioning::Middleware, @@ -105,25 +104,8 @@ RSpec.describe Gitlab::SidekiqMiddleware do end context "all optional middlewares on" do - context "when load balancing is enabled" do - before do - allow(::Gitlab::Database::LoadBalancing).to receive_message_chain(:proxy, :load_balancer, :release_host) - end - - it_behaves_like "a middleware chain", true - it_behaves_like "a middleware chain for mailer", true - end - - context "when load balancing is disabled" do - let(:disabled_sidekiq_middlewares) do - [ - Gitlab::Database::LoadBalancing::SidekiqServerMiddleware - ] - end - - it_behaves_like "a middleware chain", false - it_behaves_like "a middleware chain for mailer", false - end + it_behaves_like "a middleware chain" + it_behaves_like "a middleware chain for mailer" end context "all optional middlewares off" do @@ -135,36 +117,16 @@ RSpec.describe Gitlab::SidekiqMiddleware do ) end - context "when load balancing is enabled" do - let(:disabled_sidekiq_middlewares) do - [ - Gitlab::SidekiqMiddleware::ServerMetrics, - Gitlab::SidekiqMiddleware::ArgumentsLogger, - Gitlab::SidekiqMiddleware::MemoryKiller - ] - end - - before do - allow(::Gitlab::Database::LoadBalancing).to receive_message_chain(:proxy, :load_balancer, :release_host) - end - - it_behaves_like "a middleware chain", true - it_behaves_like "a middleware chain for mailer", true + let(:disabled_sidekiq_middlewares) do + [ + Gitlab::SidekiqMiddleware::ServerMetrics, + Gitlab::SidekiqMiddleware::ArgumentsLogger, + Gitlab::SidekiqMiddleware::MemoryKiller + ] end - context "when load balancing is disabled" do - let(:disabled_sidekiq_middlewares) do - [ - Gitlab::SidekiqMiddleware::ServerMetrics, - Gitlab::SidekiqMiddleware::ArgumentsLogger, - Gitlab::SidekiqMiddleware::MemoryKiller, - Gitlab::Database::LoadBalancing::SidekiqServerMiddleware - ] - end - - it_behaves_like "a middleware chain", false - it_behaves_like "a middleware chain for mailer", false - end + it_behaves_like "a middleware chain" + it_behaves_like "a middleware chain for mailer" end end @@ -186,30 +148,7 @@ RSpec.describe Gitlab::SidekiqMiddleware do ] end - context "when load balancing is disabled" do - let(:disabled_sidekiq_middlewares) do - [ - Gitlab::Database::LoadBalancing::SidekiqClientMiddleware - ] - end - - it_behaves_like "a middleware chain", false - it_behaves_like "a middleware chain for mailer", false - - # Sidekiq documentation states that the worker class could be a string - # or a class reference. We should test for both - context "worker_class as string value" do - let(:worker_args) { [worker_class.to_s, { 'args' => job_args }, queue, redis_pool] } - let(:middleware_expected_args) { [worker_class.to_s, hash_including({ 'args' => job_args }), queue, redis_pool] } - - it_behaves_like "a middleware chain", false - it_behaves_like "a middleware chain for mailer", false - end - end - - context "when load balancing is enabled" do - it_behaves_like "a middleware chain", true - it_behaves_like "a middleware chain for mailer", true - end + it_behaves_like "a middleware chain" + it_behaves_like "a middleware chain for mailer" end end diff --git a/spec/lib/gitlab/sidekiq_migrate_jobs_spec.rb b/spec/lib/gitlab/sidekiq_migrate_jobs_spec.rb index b30143ed196..d4391d3023a 100644 --- a/spec/lib/gitlab/sidekiq_migrate_jobs_spec.rb +++ b/spec/lib/gitlab/sidekiq_migrate_jobs_spec.rb @@ -65,7 +65,7 @@ RSpec.describe Gitlab::SidekiqMigrateJobs, :clean_gitlab_redis_queues do expect(item).to include('queue' => 'post_receive', 'args' => [i]) end - expect(score).to eq(i.succ.hours.from_now.to_i) + expect(score).to be_within(schedule_jitter).of(i.succ.hours.from_now.to_i) end end end @@ -84,7 +84,7 @@ RSpec.describe Gitlab::SidekiqMigrateJobs, :clean_gitlab_redis_queues do expect(item).to include('queue' => 'another_queue', 'args' => [i]) end - expect(score).to eq(i.succ.hours.from_now.to_i) + expect(score).to be_within(schedule_jitter).of(i.succ.hours.from_now.to_i) end end end @@ -98,7 +98,7 @@ RSpec.describe Gitlab::SidekiqMigrateJobs, :clean_gitlab_redis_queues do set_after.each.with_index do |(item, score), i| expect(item).to include('queue' => 'new_queue', 'args' => [i]) - expect(score).to eq(i.succ.hours.from_now.to_i) + expect(score).to be_within(schedule_jitter).of(i.succ.hours.from_now.to_i) end end end @@ -173,6 +173,7 @@ RSpec.describe Gitlab::SidekiqMigrateJobs, :clean_gitlab_redis_queues do context 'scheduled jobs' do let(:set_name) { 'schedule' } + let(:schedule_jitter) { 0 } def create_jobs(include_post_receive: true) AuthorizedProjectsWorker.perform_in(1.hour, 0) @@ -186,12 +187,14 @@ RSpec.describe Gitlab::SidekiqMigrateJobs, :clean_gitlab_redis_queues do context 'retried jobs' do let(:set_name) { 'retry' } + # Account for Sidekiq retry jitter + # https://github.com/mperham/sidekiq/blob/3575ccb44c688dd08bfbfd937696260b12c622fb/lib/sidekiq/job_retry.rb#L217 + let(:schedule_jitter) { 10 } # Try to mimic as closely as possible what Sidekiq will actually # do to retry a job. def retry_in(klass, time, args) - # In Sidekiq 6, this argument will become a JSON string - message = { 'class' => klass, 'args' => [args], 'retry' => true } + message = { 'class' => klass.name, 'args' => [args], 'retry' => true }.to_json allow(klass).to receive(:sidekiq_retry_in_block).and_return(proc { time }) diff --git a/spec/lib/gitlab/sidekiq_versioning/manager_spec.rb b/spec/lib/gitlab/sidekiq_versioning/manager_spec.rb deleted file mode 100644 index 84161d9236e..00000000000 --- a/spec/lib/gitlab/sidekiq_versioning/manager_spec.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::SidekiqVersioning::Manager do - before do - Sidekiq::Manager.prepend described_class - end - - describe '#initialize' do - it 'listens on all expanded queues' do - manager = Sidekiq::Manager.new(queues: %w[post_receive repository_fork cronjob unknown]) - - queues = manager.options[:queues] - - expect(queues).to include('post_receive') - expect(queues).to include('repository_fork') - expect(queues).to include('cronjob') - expect(queues).to include('cronjob:import_stuck_project_import_jobs') - expect(queues).to include('cronjob:jira_import_stuck_jira_import_jobs') - expect(queues).to include('cronjob:stuck_merge_jobs') - expect(queues).to include('unknown') - end - end -end diff --git a/spec/lib/gitlab/sidekiq_versioning_spec.rb b/spec/lib/gitlab/sidekiq_versioning_spec.rb index ed9650fc166..afafd04d87d 100644 --- a/spec/lib/gitlab/sidekiq_versioning_spec.rb +++ b/spec/lib/gitlab/sidekiq_versioning_spec.rb @@ -29,12 +29,6 @@ RSpec.describe Gitlab::SidekiqVersioning, :redis do end describe '.install!' do - it 'prepends SidekiqVersioning::Manager into Sidekiq::Manager' do - described_class.install! - - expect(Sidekiq::Manager).to include(Gitlab::SidekiqVersioning::Manager) - end - it 'registers all versionless and versioned queues with Redis' do described_class.install! diff --git a/spec/lib/gitlab/slash_commands/issue_move_spec.rb b/spec/lib/gitlab/slash_commands/issue_move_spec.rb index 5fffbb2d4cc..aa1341b4148 100644 --- a/spec/lib/gitlab/slash_commands/issue_move_spec.rb +++ b/spec/lib/gitlab/slash_commands/issue_move_spec.rb @@ -95,7 +95,7 @@ RSpec.describe Gitlab::SlashCommands::IssueMove, service: true do end end - context 'when the user cannot see the target project' do + context 'when the user cannot see the target project', :sidekiq_inline do it 'returns not found' do message = "issue move #{issue.iid} #{other_project.full_path}" other_project.team.truncate diff --git a/spec/lib/gitlab/subscription_portal_spec.rb b/spec/lib/gitlab/subscription_portal_spec.rb index 628eb380396..a3808b0f0e2 100644 --- a/spec/lib/gitlab/subscription_portal_spec.rb +++ b/spec/lib/gitlab/subscription_portal_spec.rb @@ -5,23 +5,96 @@ require 'spec_helper' RSpec.describe ::Gitlab::SubscriptionPortal do using RSpec::Parameterized::TableSyntax - where(:method_name, :test, :development, :result) do - :default_subscriptions_url | false | false | 'https://customers.gitlab.com' - :default_subscriptions_url | false | true | 'https://customers.stg.gitlab.com' - :default_subscriptions_url | true | false | 'https://customers.stg.gitlab.com' - :payment_form_url | false | false | 'https://customers.gitlab.com/payment_forms/cc_validation' - :payment_form_url | false | true | 'https://customers.stg.gitlab.com/payment_forms/cc_validation' - :payment_form_url | true | false | 'https://customers.stg.gitlab.com/payment_forms/cc_validation' + let(:env_value) { nil } + + before do + stub_env('CUSTOMER_PORTAL_URL', env_value) + stub_feature_flags(new_customersdot_staging_url: false) end - with_them do - subject { described_class.method(method_name).call } + describe '.default_subscriptions_url' do + where(:test, :development, :result) do + false | false | 'https://customers.gitlab.com' + false | true | 'https://customers.stg.gitlab.com' + true | false | 'https://customers.stg.gitlab.com' + end before do allow(Rails).to receive_message_chain(:env, :test?).and_return(test) allow(Rails).to receive_message_chain(:env, :development?).and_return(development) end - it { is_expected.to eq(result) } + with_them do + subject { described_class.default_subscriptions_url } + + it { is_expected.to eq(result) } + end + end + + describe '.subscriptions_url' do + subject { described_class.subscriptions_url } + + context 'when CUSTOMER_PORTAL_URL ENV is unset' do + it { is_expected.to eq('https://customers.stg.gitlab.com') } + end + + context 'when CUSTOMER_PORTAL_URL ENV is set' do + let(:env_value) { 'https://customers.example.com' } + + it { is_expected.to eq(env_value) } + end + end + + describe '.subscriptions_comparison_url' do + subject { described_class.subscriptions_comparison_url } + + link_match = %r{\Ahttps://about\.gitlab\.((cn/pricing/saas)|(com/pricing/gitlab-com))/feature-comparison\z} + + it { is_expected.to match(link_match) } + end + + context 'url methods' do + where(:method_name, :result) do + :default_subscriptions_url | 'https://customers.stg.gitlab.com' + :payment_form_url | 'https://customers.stg.gitlab.com/payment_forms/cc_validation' + :subscriptions_graphql_url | 'https://customers.stg.gitlab.com/graphql' + :subscriptions_more_minutes_url | 'https://customers.stg.gitlab.com/buy_pipeline_minutes' + :subscriptions_more_storage_url | 'https://customers.stg.gitlab.com/buy_storage' + :subscriptions_manage_url | 'https://customers.stg.gitlab.com/subscriptions' + :subscriptions_plans_url | 'https://customers.stg.gitlab.com/plans' + :subscriptions_instance_review_url | 'https://customers.stg.gitlab.com/instance_review' + :subscriptions_gitlab_plans_url | 'https://customers.stg.gitlab.com/gitlab_plans' + end + + with_them do + subject { described_class.send(method_name) } + + it { is_expected.to eq(result) } + end + end + + describe '.add_extra_seats_url' do + subject { described_class.add_extra_seats_url(group_id) } + + let(:group_id) { 153 } + + it { is_expected.to eq("https://customers.stg.gitlab.com/gitlab/namespaces/#{group_id}/extra_seats") } + end + + describe '.upgrade_subscription_url' do + subject { described_class.upgrade_subscription_url(group_id, plan_id) } + + let(:group_id) { 153 } + let(:plan_id) { 5 } + + it { is_expected.to eq("https://customers.stg.gitlab.com/gitlab/namespaces/#{group_id}/upgrade/#{plan_id}") } + end + + describe '.renew_subscription_url' do + subject { described_class.renew_subscription_url(group_id) } + + let(:group_id) { 153 } + + it { is_expected.to eq("https://customers.stg.gitlab.com/gitlab/namespaces/#{group_id}/renew") } end end diff --git a/spec/lib/gitlab/tracking/docs/helper_spec.rb b/spec/lib/gitlab/tracking/docs/helper_spec.rb deleted file mode 100644 index 5f7965502f1..00000000000 --- a/spec/lib/gitlab/tracking/docs/helper_spec.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Tracking::Docs::Helper do - let_it_be(:klass) do - Class.new do - include Gitlab::Tracking::Docs::Helper - end - end - - describe '#auto_generated_comment' do - it 'renders information about missing description' do - expect(klass.new.auto_generated_comment).to match /This documentation is auto generated by a script/ - end - end - - describe '#render_description' do - context 'description is empty' do - it 'renders information about missing description' do - object = double(description: '') - - expect(klass.new.render_description(object)).to eq('Missing description') - end - end - - context 'description is present' do - it 'render description' do - object = double(description: 'some description') - - expect(klass.new.render_description(object)).to eq('some description') - end - end - end - - describe '#render_event_taxonomy' do - it 'render table with event taxonomy' do - attributes = { - category: 'epics', - action: 'promote', - label: nil, - property_description: 'String with issue id', - value_description: 'Integer issue id' - } - object = double(attributes: attributes) - event_taxonomy = <<~MD.chomp - | category | action | label | property | value | - |---|---|---|---|---| - | `epics` | `promote` | `` | `String with issue id` | `Integer issue id` | - MD - - expect(klass.new.render_event_taxonomy(object)).to eq(event_taxonomy) - end - end - - describe '#md_link_to' do - it 'render link in md format' do - expect(klass.new.md_link_to('zelda', 'link')).to eq('[zelda](link)') - end - end - - describe '#render_owner' do - it 'render information about group owning event' do - object = double(product_group: "group::product intelligence") - - expect(klass.new.render_owner(object)).to eq("Owner: `group::product intelligence`") - end - end - - describe '#render_tiers' do - it 'render information about tiers' do - object = double(tiers: %w[bronze silver gold]) - - expect(klass.new.render_tiers(object)).to eq("Tiers: `bronze`, `silver`, `gold`") - end - end - - describe '#render_yaml_definition_path' do - it 'render relative location of yaml definition' do - object = double(yaml_path: 'config/events/button_click.yaml') - - expect(klass.new.render_yaml_definition_path(object)).to eq("YAML definition: `config/events/button_click.yaml`") - end - end - - describe '#backtick' do - it 'wraps string in backticks chars' do - expect(klass.new.backtick('test')).to eql("`test`") - end - end -end diff --git a/spec/lib/gitlab/tracking/docs/renderer_spec.rb b/spec/lib/gitlab/tracking/docs/renderer_spec.rb deleted file mode 100644 index 386aea6c23a..00000000000 --- a/spec/lib/gitlab/tracking/docs/renderer_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Tracking::Docs::Renderer do - describe 'contents' do - let(:dictionary_path) { described_class::DICTIONARY_PATH } - let(:items) { Gitlab::Tracking::EventDefinition.definitions.first(10).to_h } - - it 'generates dictionary for given items' do - generated_dictionary = described_class.new(items).contents - table_of_contents_items = items.values.map { |item| "#{item.category} #{item.action}"} - - generated_dictionary_keys = RDoc::Markdown - .parse(generated_dictionary) - .table_of_contents - .select { |metric_doc| metric_doc.level == 3 } - .map { |item| item.text.match(%r{<code>(.*)</code>})&.captures&.first } - - expect(generated_dictionary_keys).to match_array(table_of_contents_items) - end - end -end diff --git a/spec/lib/gitlab/tracking/standard_context_spec.rb b/spec/lib/gitlab/tracking/standard_context_spec.rb index ca7a6b6b1c3..8ded80dd191 100644 --- a/spec/lib/gitlab/tracking/standard_context_spec.rb +++ b/spec/lib/gitlab/tracking/standard_context_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe Gitlab::Tracking::StandardContext do let_it_be(:project) { create(:project) } let_it_be(:namespace) { create(:namespace) } + let_it_be(:user) { create(:user) } let(:snowplow_context) { subject.to_context } @@ -87,8 +88,8 @@ RSpec.describe Gitlab::Tracking::StandardContext do end end - it 'does not contain user id' do - expect(snowplow_context.to_json[:data].keys).not_to include(:user_id) + it 'contains user id' do + expect(snowplow_context.to_json[:data].keys).to include(:user_id) end it 'contains namespace and project ids' do @@ -104,8 +105,18 @@ RSpec.describe Gitlab::Tracking::StandardContext do stub_feature_flags(add_namespace_and_project_to_snowplow_tracking: false) end - it 'does not contain any ids' do - expect(snowplow_context.to_json[:data].keys).not_to include(:user_id, :project_id, :namespace_id) + it 'does not contain project or namespace ids' do + expect(snowplow_context.to_json[:data].keys).not_to include(:project_id, :namespace_id) + end + end + + context 'without add_actor_based_user_to_snowplow_tracking feature' do + before do + stub_feature_flags(add_actor_based_user_to_snowplow_tracking: false) + end + + it 'does not contain user_id' do + expect(snowplow_context.to_json[:data].keys).not_to include(:user_id) end end end diff --git a/spec/lib/gitlab/tracking_spec.rb b/spec/lib/gitlab/tracking_spec.rb index 02e66458f46..dacaae55676 100644 --- a/spec/lib/gitlab/tracking_spec.rb +++ b/spec/lib/gitlab/tracking_spec.rb @@ -48,7 +48,7 @@ RSpec.describe Gitlab::Tracking do other_context = double(:context) project = build_stubbed(:project) - user = double(:user) + user = build_stubbed(:user) expect(Gitlab::Tracking::StandardContext) .to receive(:new) diff --git a/spec/lib/gitlab/usage/metric_definition_spec.rb b/spec/lib/gitlab/usage/metric_definition_spec.rb index 6406c0b5458..522f69062fb 100644 --- a/spec/lib/gitlab/usage/metric_definition_spec.rb +++ b/spec/lib/gitlab/usage/metric_definition_spec.rb @@ -49,6 +49,37 @@ RSpec.describe Gitlab::Usage::MetricDefinition do expect { described_class.definitions }.not_to raise_error end + describe '#with_instrumentation_class' do + let(:metric_status) { 'active' } + let(:all_definitions) do + metrics_definitions = [ + { key_path: 'metric1', instrumentation_class: 'RedisHLLMetric', status: 'data_available' }, + { key_path: 'metric2', instrumentation_class: 'RedisHLLMetric', status: 'implemented' }, + { key_path: 'metric3', instrumentation_class: 'RedisHLLMetric', status: 'deprecated' }, + { key_path: 'metric4', instrumentation_class: 'RedisHLLMetric', status: metric_status }, + { key_path: 'metric5', status: 'active' }, + { key_path: 'metric_missing_status' } + ] + metrics_definitions.map { |definition| described_class.new(definition[:key_path], definition.symbolize_keys) } + end + + before do + allow(described_class).to receive(:all).and_return(all_definitions) + end + + it 'includes definitions with instrumentation_class' do + expect(described_class.with_instrumentation_class.count).to eq(4) + end + + context 'with removed metric' do + let(:metric_status) { 'removed' } + + it 'excludes removed definitions' do + expect(described_class.with_instrumentation_class.count).to eq(3) + end + end + end + describe '#key' do subject { definition.key } diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/active_user_count_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/active_user_count_metric_spec.rb new file mode 100644 index 00000000000..f0ee6c38f2e --- /dev/null +++ b/spec/lib/gitlab/usage/metrics/instrumentations/active_user_count_metric_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Usage::Metrics::Instrumentations::ActiveUserCountMetric do + before do + create(:user) + end + + it_behaves_like 'a correct instrumented metric value', { time_frame: 'none', data_source: 'ruby' } do + let(:expected_value) { ::User.active.count } + end +end diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/count_users_associating_milestones_to_releases_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/count_users_associating_milestones_to_releases_metric_spec.rb new file mode 100644 index 00000000000..e2bb99c832a --- /dev/null +++ b/spec/lib/gitlab/usage/metrics/instrumentations/count_users_associating_milestones_to_releases_metric_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CountUsersAssociatingMilestonesToReleasesMetric do + let_it_be(:release) { create(:release, created_at: 3.days.ago) } + let_it_be(:release_with_milestone) { create(:release, :with_milestones, created_at: 3.days.ago) } + + it_behaves_like 'a correct instrumented metric value', { time_frame: '28d', data_source: 'database' } do + let(:expected_value) { 1 } + end +end diff --git a/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb index 4996b0a0089..222198a58ac 100644 --- a/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb @@ -6,97 +6,62 @@ RSpec.describe Gitlab::UsageDataCounters::CiTemplateUniqueCounter do describe '.track_unique_project_event' do using RSpec::Parameterized::TableSyntax - where(:template, :config_source, :expected_event) do - # Implicit Auto DevOps usage - 'Auto-DevOps.gitlab-ci.yml' | :auto_devops_source | 'p_ci_templates_implicit_auto_devops' - 'Jobs/Build.gitlab-ci.yml' | :auto_devops_source | 'p_ci_templates_implicit_auto_devops_build' - 'Jobs/Deploy.gitlab-ci.yml' | :auto_devops_source | 'p_ci_templates_implicit_auto_devops_deploy' - 'Security/SAST.gitlab-ci.yml' | :auto_devops_source | 'p_ci_templates_implicit_security_sast' - 'Security/Secret-Detection.gitlab-ci.yml' | :auto_devops_source | 'p_ci_templates_implicit_security_secret_detection' - # Explicit include:template usage - '5-Minute-Production-App.gitlab-ci.yml' | :repository_source | 'p_ci_templates_5_min_production_app' - 'Auto-DevOps.gitlab-ci.yml' | :repository_source | 'p_ci_templates_auto_devops' - 'AWS/CF-Provision-and-Deploy-EC2.gitlab-ci.yml' | :repository_source | 'p_ci_templates_aws_cf_deploy_ec2' - 'AWS/Deploy-ECS.gitlab-ci.yml' | :repository_source | 'p_ci_templates_aws_deploy_ecs' - 'Jobs/Build.gitlab-ci.yml' | :repository_source | 'p_ci_templates_auto_devops_build' - 'Jobs/Deploy.gitlab-ci.yml' | :repository_source | 'p_ci_templates_auto_devops_deploy' - 'Jobs/Deploy.latest.gitlab-ci.yml' | :repository_source | 'p_ci_templates_auto_devops_deploy_latest' - 'Security/SAST.gitlab-ci.yml' | :repository_source | 'p_ci_templates_security_sast' - 'Security/Secret-Detection.gitlab-ci.yml' | :repository_source | 'p_ci_templates_security_secret_detection' - 'Terraform/Base.latest.gitlab-ci.yml' | :repository_source | 'p_ci_templates_terraform_base_latest' - end - - with_them do - it_behaves_like 'tracking unique hll events' do - subject(:request) { described_class.track_unique_project_event(project_id: project_id, template: template, config_source: config_source) } + let(:project_id) { 1 } - let(:project_id) { 1 } - let(:target_id) { expected_event } - let(:expected_type) { instance_of(Integer) } + shared_examples 'tracks template' do + it "has an event defined for template" do + expect do + described_class.track_unique_project_event( + project_id: project_id, + template: template_path, + config_source: config_source + ) + end.not_to raise_error end - end - context 'known_events coverage tests' do - let(:project_id) { 1 } - let(:config_source) { :repository_source } + it "tracks template" do + expanded_template_name = described_class.expand_template_name(template_path) + expected_template_event_name = described_class.ci_template_event_name(expanded_template_name, config_source) + expect(Gitlab::UsageDataCounters::HLLRedisCounter).to(receive(:track_event)).with(expected_template_event_name, values: project_id) - # These tests help guard against missing "explicit" events in known_events/ci_templates.yml - context 'explicit include:template events' do - described_class::TEMPLATE_TO_EVENT.keys.each do |template| - it "does not raise error for #{template}" do - expect do - described_class.track_unique_project_event(project_id: project_id, template: template, config_source: config_source) - end.not_to raise_error - end - end + described_class.track_unique_project_event(project_id: project_id, template: template_path, config_source: config_source) end + end - # This test is to help guard against missing "implicit" events in known_events/ci_templates.yml - it 'does not raise error for any template in an implicit Auto DevOps pipeline' do - project = create(:project, :auto_devops) - pipeline = double(project: project) - command = double - result = Gitlab::Ci::YamlProcessor.new( - Gitlab::Ci::Pipeline::Chain::Config::Content::AutoDevops.new(pipeline, command).content, - project: project, - user: double, - sha: 'd310cc759caaa20cd05a9e0983d6017896d9c34c' - ).execute + context 'with explicit includes' do + let(:config_source) { :repository_source } - config_source = :auto_devops_source + (described_class.ci_templates - ['Verify/Browser-Performance.latest.gitlab-ci.yml', 'Verify/Browser-Performance.gitlab-ci.yml']).each do |template| + context "for #{template}" do + let(:template_path) { template } - result.included_templates.each do |template| - expect do - described_class.track_unique_project_event(project_id: project.id, template: template, config_source: config_source) - end.not_to raise_error + include_examples 'tracks template' end end end - context 'templates outside of TEMPLATE_TO_EVENT' do - let(:project_id) { 1 } - let(:config_source) { :repository_source } - - described_class.ci_templates.each do |template| - next if described_class::TEMPLATE_TO_EVENT.key?(template) - - it "has an event defined for #{template}" do - expect do - described_class.track_unique_project_event( - project_id: project_id, - template: template, - config_source: config_source - ) - end.not_to raise_error - end + context 'with implicit includes' do + let(:config_source) { :auto_devops_source } - it "tracks #{template}" do - expected_template_event_name = described_class.ci_template_event_name(template, :repository_source) - expect(Gitlab::UsageDataCounters::HLLRedisCounter).to(receive(:track_event)).with(expected_template_event_name, values: project_id) + [ + ['', ['Auto-DevOps.gitlab-ci.yml']], + ['Jobs', described_class.ci_templates('lib/gitlab/ci/templates/Jobs')], + ['Security', described_class.ci_templates('lib/gitlab/ci/templates/Security')] + ].each do |directory, templates| + templates.each do |template| + context "for #{template}" do + let(:template_path) { File.join(directory, template) } - described_class.track_unique_project_event(project_id: project_id, template: template, config_source: config_source) + include_examples 'tracks template' + end end end end + + it 'expands short template names' do + expect do + described_class.track_unique_project_event(project_id: 1, template: 'Dependency-Scanning.gitlab-ci.yml', config_source: :repository_source) + end.not_to raise_error + end 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 427dd4a205e..0ec805714e3 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 @@ -47,6 +47,7 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s 'epics_usage', 'epic_boards_usage', 'secure', + 'importer', 'network_policies' ) end diff --git a/spec/lib/gitlab/usage_data_metrics_spec.rb b/spec/lib/gitlab/usage_data_metrics_spec.rb index e0063194f9b..ee0cfb1407e 100644 --- a/spec/lib/gitlab/usage_data_metrics_spec.rb +++ b/spec/lib/gitlab/usage_data_metrics_spec.rb @@ -46,7 +46,7 @@ RSpec.describe Gitlab::UsageDataMetrics do let(:metric_files_key_paths) do Gitlab::Usage::MetricDefinition .definitions - .select { |k, v| v.attributes[:data_source] == 'redis_hll' && v.key_path.starts_with?('redis_hll_counters') } + .select { |k, v| v.attributes[:data_source] == 'redis_hll' && v.key_path.starts_with?('redis_hll_counters') && v.available? } .keys .sort end diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index a70b68a181f..833bf260019 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -101,11 +101,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do it 'includes accurate usage_activity_by_stage data' do for_defined_days_back do user = create(:user) - cluster = create(:cluster, user: user) - create(:clusters_applications_cert_manager, :installed, cluster: cluster) - create(:clusters_applications_helm, :installed, cluster: cluster) - create(:clusters_applications_ingress, :installed, cluster: cluster) - create(:clusters_applications_knative, :installed, cluster: cluster) + create(:cluster, user: user) create(:cluster, :disabled, user: user) create(:cluster_provider_gcp, :created) create(:cluster_provider_aws, :created) @@ -118,10 +114,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do end expect(described_class.usage_activity_by_stage_configure({})).to include( - clusters_applications_cert_managers: 2, - clusters_applications_helm: 2, - clusters_applications_ingress: 2, - clusters_applications_knative: 2, clusters_management_project: 2, clusters_disabled: 4, clusters_enabled: 12, @@ -136,10 +128,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do project_clusters_enabled: 10 ) expect(described_class.usage_activity_by_stage_configure(described_class.monthly_time_range_db_params)).to include( - clusters_applications_cert_managers: 1, - clusters_applications_helm: 1, - clusters_applications_ingress: 1, - clusters_applications_knative: 1, clusters_management_project: 1, clusters_disabled: 2, clusters_enabled: 6, @@ -392,7 +380,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do user = create(:user, dashboard: 'operations') cluster = create(:cluster, user: user) project = create(:project, creator: user) - create(:clusters_applications_prometheus, :installed, cluster: cluster) + create(:clusters_integrations_prometheus, cluster: cluster) create(:project_tracing_setting) create(:project_error_tracking_setting) create(:incident) @@ -402,7 +390,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do expect(described_class.usage_activity_by_stage_monitor({})).to include( clusters: 2, - clusters_applications_prometheus: 2, + clusters_integrations_prometheus: 2, operations_dashboard_default_dashboard: 2, projects_with_tracing_enabled: 2, projects_with_error_tracking_enabled: 2, @@ -414,7 +402,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do data_28_days = described_class.usage_activity_by_stage_monitor(described_class.monthly_time_range_db_params) expect(data_28_days).to include( clusters: 1, - clusters_applications_prometheus: 1, + clusters_integrations_prometheus: 1, operations_dashboard_default_dashboard: 1, projects_with_tracing_enabled: 1, projects_with_error_tracking_enabled: 1, @@ -469,7 +457,8 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do for_defined_days_back do user = create(:user) create(:deployment, :failed, user: user) - create(:release, author: user) + release = create(:release, author: user) + create(:milestone, project: release.project, releases: [release]) create(:deployment, :success, user: user) end @@ -477,13 +466,15 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do deployments: 2, failed_deployments: 2, releases: 2, - successful_deployments: 2 + successful_deployments: 2, + releases_with_milestones: 2 ) expect(described_class.usage_activity_by_stage_release(described_class.monthly_time_range_db_params)).to include( deployments: 1, failed_deployments: 1, releases: 1, - successful_deployments: 1 + successful_deployments: 1, + releases_with_milestones: 1 ) end end @@ -499,7 +490,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do create(:ci_pipeline, :repository_source, user: user) create(:ci_pipeline_schedule, owner: user) create(:ci_trigger, owner: user) - create(:clusters_applications_runner, :installed) end expect(described_class.usage_activity_by_stage_verify({})).to include( @@ -510,8 +500,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do ci_pipeline_config_repository: 2, ci_pipeline_schedules: 2, ci_pipelines: 2, - ci_triggers: 2, - clusters_applications_runner: 2 + ci_triggers: 2 ) expect(described_class.usage_activity_by_stage_verify(described_class.monthly_time_range_db_params)).to include( ci_builds: 1, @@ -521,8 +510,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do ci_pipeline_config_repository: 1, ci_pipeline_schedules: 1, ci_pipelines: 1, - ci_triggers: 1, - clusters_applications_runner: 1 + ci_triggers: 1 ) end end @@ -604,17 +592,9 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do expect(count_data[:clusters_platforms_eks]).to eq(1) expect(count_data[:clusters_platforms_gke]).to eq(1) expect(count_data[:clusters_platforms_user]).to eq(1) - expect(count_data[:clusters_applications_helm]).to eq(1) - expect(count_data[:clusters_applications_ingress]).to eq(1) - expect(count_data[:clusters_applications_cert_managers]).to eq(1) - expect(count_data[:clusters_applications_crossplane]).to eq(1) - expect(count_data[:clusters_applications_prometheus]).to eq(1) - expect(count_data[:clusters_applications_runner]).to eq(1) - expect(count_data[:clusters_applications_knative]).to eq(1) - expect(count_data[:clusters_applications_elastic_stack]).to eq(1) + expect(count_data[:clusters_integrations_elastic_stack]).to eq(1) + expect(count_data[:clusters_integrations_prometheus]).to eq(1) expect(count_data[:grafana_integrated_projects]).to eq(2) - expect(count_data[:clusters_applications_jupyter]).to eq(1) - expect(count_data[:clusters_applications_cilium]).to eq(1) expect(count_data[:clusters_management_project]).to eq(1) expect(count_data[:kubernetes_agents]).to eq(2) expect(count_data[:kubernetes_agents_with_token]).to eq(1) @@ -662,13 +642,13 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do subject { described_class.data[:counts] } it 'gathers usage data' do - expect(subject[:projects_with_expiration_policy_enabled]).to eq 18 + expect(subject[:projects_with_expiration_policy_enabled]).to eq 19 expect(subject[:projects_with_expiration_policy_disabled]).to eq 5 expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_unset]).to eq 1 expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_1]).to eq 1 expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_5]).to eq 1 - expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_10]).to eq 12 + expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_10]).to eq 13 expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_25]).to eq 1 expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_50]).to eq 1 @@ -676,9 +656,10 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_7d]).to eq 1 expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_14d]).to eq 1 expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_30d]).to eq 1 + expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_60d]).to eq 1 expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_90d]).to eq 14 - expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_1d]).to eq 14 + expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_1d]).to eq 15 expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_7d]).to eq 1 expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_14d]).to eq 1 expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_1month]).to eq 1 @@ -743,7 +724,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do expect(counts_monthly[:projects_with_alerts_created]).to eq(1) expect(counts_monthly[:projects]).to eq(1) expect(counts_monthly[:packages]).to eq(1) - expect(counts_monthly[:promoted_issues]).to eq(1) + expect(counts_monthly[:promoted_issues]).to eq(Gitlab::UsageData::DEPRECATED_VALUE) end end @@ -1093,6 +1074,10 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do it 'gathers service_ping_features_enabled' do expect(subject[:settings][:service_ping_features_enabled]).to eq(Gitlab::CurrentSettings.usage_ping_features_enabled) end + + it 'gathers user_cap_feature_enabled' do + expect(subject[:settings][:user_cap_feature_enabled]).to eq(Gitlab::CurrentSettings.new_user_signups_cap) + end end end @@ -1438,48 +1423,4 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do end end end - - describe '.snowplow_event_counts' do - let_it_be(:time_period) { { collector_tstamp: 8.days.ago..1.day.ago } } - - context 'when self-monitoring project exists' do - let_it_be(:project) { create(:project) } - - before do - stub_application_setting(self_monitoring_project: project) - end - - context 'and product_analytics FF is enabled for it' do - before do - stub_feature_flags(product_analytics_tracking: true) - - create(:product_analytics_event, project: project, se_category: 'epics', se_action: 'promote') - create(:product_analytics_event, project: project, se_category: 'epics', se_action: 'promote', collector_tstamp: 2.days.ago) - create(:product_analytics_event, project: project, se_category: 'epics', se_action: 'promote', collector_tstamp: 9.days.ago) - - create(:product_analytics_event, project: project, se_category: 'foo', se_action: 'bar', collector_tstamp: 2.days.ago) - end - - it 'returns promoted_issues for the time period' do - expect(described_class.snowplow_event_counts(time_period)[:promoted_issues]).to eq(1) - end - end - - context 'and product_analytics FF is disabled' do - before do - stub_feature_flags(product_analytics_tracking: false) - end - - it 'returns an empty hash' do - expect(described_class.snowplow_event_counts(time_period)).to eq({}) - end - end - end - - context 'when self-monitoring project does not exist' do - it 'returns an empty hash' do - expect(described_class.snowplow_event_counts(time_period)).to eq({}) - end - end - end end diff --git a/spec/lib/gitlab/utils/delegator_override/error_spec.rb b/spec/lib/gitlab/utils/delegator_override/error_spec.rb new file mode 100644 index 00000000000..59b67676eff --- /dev/null +++ b/spec/lib/gitlab/utils/delegator_override/error_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Utils::DelegatorOverride::Error do + let(:error) { described_class.new('foo', 'Target', '/path/to/target', 'Delegator', '/path/to/delegator') } + + describe '#to_s' do + subject { error.to_s } + + it { is_expected.to eq("Delegator#foo is overriding Target#foo. delegator_location: /path/to/delegator target_location: /path/to/target") } + end +end diff --git a/spec/lib/gitlab/utils/delegator_override/validator_spec.rb b/spec/lib/gitlab/utils/delegator_override/validator_spec.rb new file mode 100644 index 00000000000..4cd1b18de82 --- /dev/null +++ b/spec/lib/gitlab/utils/delegator_override/validator_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Utils::DelegatorOverride::Validator do + let(:delegator_class) do + Class.new(::SimpleDelegator) do + extend(::Gitlab::Utils::DelegatorOverride) + + def foo + end + end.prepend(ee_delegator_extension) + end + + let(:ee_delegator_extension) do + Module.new do + extend(::Gitlab::Utils::DelegatorOverride) + + def bar + end + end + end + + let(:target_class) do + Class.new do + def foo + end + + def bar + end + end + end + + let(:validator) { described_class.new(delegator_class) } + + describe '#add_allowlist' do + it 'adds a method name to the allowlist' do + validator.add_allowlist([:foo]) + + expect(validator.allowed_method_names).to contain_exactly(:foo) + end + end + + describe '#add_target' do + it 'adds the target class' do + validator.add_target(target_class) + + expect(validator.target_classes).to contain_exactly(target_class) + end + end + + describe '#expand_on_ancestors' do + it 'adds the allowlist in the ancestors' do + ancestor_validator = described_class.new(ee_delegator_extension) + ancestor_validator.add_allowlist([:bar]) + validator.expand_on_ancestors( { ee_delegator_extension => ancestor_validator }) + + expect(validator.allowed_method_names).to contain_exactly(:bar) + end + end + + describe '#validate_overrides!' do + before do + validator.add_target(target_class) + end + + it 'does not raise an error when the overrides are allowed' do + validator.add_allowlist([:foo]) + ancestor_validator = described_class.new(ee_delegator_extension) + ancestor_validator.add_allowlist([:bar]) + validator.expand_on_ancestors( { ee_delegator_extension => ancestor_validator }) + + expect { validator.validate_overrides! }.not_to raise_error + end + + it 'raises an error when there is an override' do + expect { validator.validate_overrides! } + .to raise_error(described_class::UnexpectedDelegatorOverrideError) + end + end +end diff --git a/spec/lib/gitlab/utils/delegator_override_spec.rb b/spec/lib/gitlab/utils/delegator_override_spec.rb new file mode 100644 index 00000000000..af4c7fa5d8e --- /dev/null +++ b/spec/lib/gitlab/utils/delegator_override_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Utils::DelegatorOverride do + let(:delegator_class) do + Class.new(::SimpleDelegator) do + extend(::Gitlab::Utils::DelegatorOverride) + + def foo + end + end + end + + let(:target_class) do + Class.new do + def foo + end + + def bar + end + end + end + + let(:dummy_module) do + Module.new do + def foobar + end + end + end + + before do + stub_env('STATIC_VERIFICATION', 'true') + end + + describe '.delegator_target' do + subject { delegator_class.delegator_target(target_class) } + + it 'sets the delegator target to the validator' do + expect(described_class.validator(delegator_class)) + .to receive(:add_target).with(target_class) + + subject + end + + context 'when the class does not inherit SimpleDelegator' do + let(:delegator_class) do + Class.new do + extend(::Gitlab::Utils::DelegatorOverride) + end + end + + it 'raises an error' do + expect { subject }.to raise_error(ArgumentError, /not a subclass of 'SimpleDelegator' class/) + end + end + end + + describe '.delegator_override' do + subject { delegator_class.delegator_override(:foo) } + + it 'adds the method name to the allowlist' do + expect(described_class.validator(delegator_class)) + .to receive(:add_allowlist).with([:foo]) + + subject + end + end + + describe '.delegator_override_with' do + subject { delegator_class.delegator_override_with(dummy_module) } + + it 'adds the method names of the module to the allowlist' do + expect(described_class.validator(delegator_class)) + .to receive(:add_allowlist).with([:foobar]) + + subject + end + end + + describe '.verify!' do + subject { described_class.verify! } + + it 'does not raise an error when an override is in allowlist' do + delegator_class.delegator_target(target_class) + delegator_class.delegator_override(:foo) + + expect { subject }.not_to raise_error + end + + it 'raises an error when there is an override' do + delegator_class.delegator_target(target_class) + + expect { subject }.to raise_error(Gitlab::Utils::DelegatorOverride::Validator::UnexpectedDelegatorOverrideError) + end + end +end diff --git a/spec/lib/gitlab/view/presenter/base_spec.rb b/spec/lib/gitlab/view/presenter/base_spec.rb index 97d5e2b280d..a7083bd2722 100644 --- a/spec/lib/gitlab/view/presenter/base_spec.rb +++ b/spec/lib/gitlab/view/presenter/base_spec.rb @@ -18,11 +18,43 @@ RSpec.describe Gitlab::View::Presenter::Base do describe '.presents' do it 'exposes #subject with the given keyword' do - presenter_class.presents(:foo) + presenter_class.presents(Object, as: :foo) presenter = presenter_class.new(project) expect(presenter.foo).to eq(project) end + + it 'raises an error when symbol is passed' do + expect { presenter_class.presents(:foo) }.to raise_error(ArgumentError) + end + + context 'when the presenter class inherits Presenter::Delegated' do + let(:presenter_class) do + Class.new(::Gitlab::View::Presenter::Delegated) do + include(::Gitlab::View::Presenter::Base) + end + end + + it 'sets the delegator target' do + expect(presenter_class).to receive(:delegator_target).with(Object) + + presenter_class.presents(Object, as: :foo) + end + end + + context 'when the presenter class inherits Presenter::Simple' do + let(:presenter_class) do + Class.new(::Gitlab::View::Presenter::Simple) do + include(::Gitlab::View::Presenter::Base) + end + end + + it 'does not set the delegator target' do + expect(presenter_class).not_to receive(:delegator_target).with(Object) + + presenter_class.presents(Object, as: :foo) + end + end end describe '#can?' do diff --git a/spec/lib/gitlab/with_feature_category_spec.rb b/spec/lib/gitlab/with_feature_category_spec.rb deleted file mode 100644 index b6fe1c84b26..00000000000 --- a/spec/lib/gitlab/with_feature_category_spec.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -require 'fast_spec_helper' -require_relative "../../../lib/gitlab/with_feature_category" - -RSpec.describe Gitlab::WithFeatureCategory do - describe ".feature_category_for_action" do - let(:base_controller) do - Class.new do - include ::Gitlab::WithFeatureCategory - end - end - - let(:controller) do - Class.new(base_controller) do - feature_category :foo, %w(update edit) - feature_category :bar, %w(index show) - feature_category :quux, %w(destroy) - end - end - - let(:subclass) do - Class.new(controller) do - feature_category :baz, %w(subclass_index) - end - end - - it "is nil when nothing was defined" do - expect(base_controller.feature_category_for_action("hello")).to be_nil - end - - it "returns the expected category", :aggregate_failures do - expect(controller.feature_category_for_action("update")).to eq(:foo) - expect(controller.feature_category_for_action("index")).to eq(:bar) - expect(controller.feature_category_for_action("destroy")).to eq(:quux) - end - - it "returns the expected category for categories defined in subclasses" do - expect(subclass.feature_category_for_action("subclass_index")).to eq(:baz) - end - - it "raises an error when defining for the controller and for individual actions" do - expect do - Class.new(base_controller) do - feature_category :hello - feature_category :goodbye, [:world] - end - end.to raise_error(ArgumentError, "hello is defined for all actions, but other categories are set") - end - - it "raises an error when multiple calls define the same action" do - expect do - Class.new(base_controller) do - feature_category :hello, [:world] - feature_category :goodbye, ["world"] - end - end.to raise_error(ArgumentError, "Actions have multiple feature categories: world") - end - - it "does not raise an error when multiple calls define the same action and feature category" do - expect do - Class.new(base_controller) do - feature_category :hello, [:world] - feature_category :hello, ["world"] - end - end.not_to raise_error - end - end -end diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb index 09f90a3e5b6..8ba56af561d 100644 --- a/spec/lib/gitlab/workhorse_spec.rb +++ b/spec/lib/gitlab/workhorse_spec.rb @@ -244,13 +244,15 @@ RSpec.describe Gitlab::Workhorse do GitalyServer: { features: { 'gitaly-feature-enforce-requests-limits' => 'true' }, address: Gitlab::GitalyClient.address('default'), - token: Gitlab::GitalyClient.token('default') + token: Gitlab::GitalyClient.token('default'), + sidechannel: false } } end before do allow(Gitlab.config.gitaly).to receive(:enabled).and_return(true) + stub_feature_flags(workhorse_use_sidechannel: false) end it 'includes a Repository param' do @@ -332,6 +334,46 @@ RSpec.describe Gitlab::Workhorse do it { expect { subject }.to raise_exception('Unsupported action: download') } end + + context 'when workhorse_use_sidechannel flag is set' do + context 'when a feature flag is set globally' do + before do + stub_feature_flags(workhorse_use_sidechannel: true) + end + + it 'sets the flag to true' do + response = described_class.git_http_ok(repository, Gitlab::GlRepository::PROJECT, user, action) + + expect(response.dig(:GitalyServer, :sidechannel)).to eq(true) + end + end + + context 'when a feature flag is set for a single project' do + before do + stub_feature_flags(workhorse_use_sidechannel: project) + end + + it 'sets the flag to true for that project' do + response = described_class.git_http_ok(repository, Gitlab::GlRepository::PROJECT, user, action) + + expect(response.dig(:GitalyServer, :sidechannel)).to eq(true) + end + + it 'sets the flag to false for other projects' do + other_project = create(:project, :public, :repository) + response = described_class.git_http_ok(other_project.repository, Gitlab::GlRepository::PROJECT, user, action) + + expect(response.dig(:GitalyServer, :sidechannel)).to eq(false) + end + + it 'sets the flag to false when there is no project' do + snippet = create(:personal_snippet, :repository) + response = described_class.git_http_ok(snippet.repository, Gitlab::GlRepository::SNIPPET, user, action) + + expect(response.dig(:GitalyServer, :sidechannel)).to eq(false) + end + end + end end context 'when receive_max_input_size has been updated' do diff --git a/spec/lib/gitlab/email/smime/certificate_spec.rb b/spec/lib/gitlab/x509/certificate_spec.rb index f7bb933e348..a5b192dd051 100644 --- a/spec/lib/gitlab/email/smime/certificate_spec.rb +++ b/spec/lib/gitlab/x509/certificate_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::Email::Smime::Certificate do +RSpec.describe Gitlab::X509::Certificate do include SmimeHelper # cert generation is an expensive operation and they are used read-only, diff --git a/spec/lib/peek/views/active_record_spec.rb b/spec/lib/peek/views/active_record_spec.rb index 6d50922904e..c89f6a21b35 100644 --- a/spec/lib/peek/views/active_record_spec.rb +++ b/spec/lib/peek/views/active_record_spec.rb @@ -53,154 +53,82 @@ RSpec.describe Peek::Views::ActiveRecord, :request_store do allow(connection_primary_2).to receive(:transaction_open?).and_return(true) allow(connection_unknown).to receive(:transaction_open?).and_return(false) allow(::Gitlab::Database).to receive(:db_config_name).and_return('the_db_config_name') + + allow(Gitlab::Database::LoadBalancing).to receive(:db_role_for_connection).with(connection_replica).and_return(:replica) + allow(Gitlab::Database::LoadBalancing).to receive(:db_role_for_connection).with(connection_primary_1).and_return(:primary) + allow(Gitlab::Database::LoadBalancing).to receive(:db_role_for_connection).with(connection_primary_2).and_return(:primary) + allow(Gitlab::Database::LoadBalancing).to receive(:db_role_for_connection).with(connection_unknown).and_return(nil) end - context 'when database load balancing is not enabled' do - 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) - ActiveSupport::Notifications.publish('sql.active_record', Time.current, Time.current + 4.seconds, '4', event_4) - end + it 'includes db role data and db_config_name name' 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) + ActiveSupport::Notifications.publish('sql.active_record', Time.current, Time.current + 4.seconds, '4', event_4) + end - expect(subject.results).to match( - calls: 4, - summary: { - "Cached" => 1, - "In a transaction" => 1 - }, - duration: '10000.00ms', - warnings: ["active-record duration: 10000.0 over 3000"], - details: contain_exactly( - a_hash_including( - start: be_a(Time), - cached: '', - transaction: '', - duration: 1000.0, - sql: 'SELECT * FROM users WHERE id = 10', - db_config_name: "Config name: the_db_config_name" - ), - a_hash_including( - start: be_a(Time), - cached: 'Cached', - transaction: '', - duration: 2000.0, - sql: 'SELECT * FROM users WHERE id = 10', - db_config_name: "Config name: the_db_config_name" - ), - a_hash_including( - start: be_a(Time), - cached: '', - transaction: 'In a transaction', - duration: 3000.0, - sql: 'UPDATE users SET admin = true WHERE id = 10', - db_config_name: "Config name: the_db_config_name" - ), - a_hash_including( - start: be_a(Time), - cached: '', - transaction: '', - duration: 4000.0, - sql: 'SELECT VERSION()', - db_config_name: "Config name: the_db_config_name" - ) + expect(subject.results).to match( + calls: 4, + summary: { + "Cached" => 1, + "In a transaction" => 1, + "Role: Primary" => 2, + "Role: Replica" => 1, + "Role: Unknown" => 1 + }, + duration: '10000.00ms', + warnings: ["active-record duration: 10000.0 over 3000"], + details: contain_exactly( + a_hash_including( + start: be_a(Time), + cached: '', + transaction: '', + duration: 1000.0, + sql: 'SELECT * FROM users WHERE id = 10', + db_role: 'Role: Primary', + db_config_name: "Config name: the_db_config_name" + ), + a_hash_including( + start: be_a(Time), + cached: 'Cached', + transaction: '', + duration: 2000.0, + sql: 'SELECT * FROM users WHERE id = 10', + db_role: 'Role: Replica', + db_config_name: "Config name: the_db_config_name" + ), + a_hash_including( + start: be_a(Time), + cached: '', + transaction: 'In a transaction', + duration: 3000.0, + sql: 'UPDATE users SET admin = true WHERE id = 10', + db_role: 'Role: Primary', + db_config_name: "Config name: the_db_config_name" + ), + a_hash_including( + start: be_a(Time), + cached: '', + transaction: '', + duration: 4000.0, + sql: 'SELECT VERSION()', + db_role: 'Role: Unknown', + db_config_name: "Config name: the_db_config_name" ) ) - end - - context 'when the GITLAB_MULTIPLE_DATABASE_METRICS env var is disabled' do - before do - stub_env('GITLAB_MULTIPLE_DATABASE_METRICS', nil) - end - - it 'does not include db_config_name field' do - ActiveSupport::Notifications.publish('sql.active_record', Time.current, Time.current + 1.second, '1', event_1) - - expect(subject.results[:details][0][:db_config_name]).to be_nil - end - end + ) end - context 'when database load balancing is enabled' do + context 'when the GITLAB_MULTIPLE_DATABASE_METRICS env var is disabled' do before do - allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(true) - allow(Gitlab::Database::LoadBalancing).to receive(:db_role_for_connection).with(connection_replica).and_return(:replica) - allow(Gitlab::Database::LoadBalancing).to receive(:db_role_for_connection).with(connection_primary_1).and_return(:primary) - allow(Gitlab::Database::LoadBalancing).to receive(:db_role_for_connection).with(connection_primary_2).and_return(:primary) - allow(Gitlab::Database::LoadBalancing).to receive(:db_role_for_connection).with(connection_unknown).and_return(nil) + stub_env('GITLAB_MULTIPLE_DATABASE_METRICS', nil) end - it 'includes db role data and db_config_name name' 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) - ActiveSupport::Notifications.publish('sql.active_record', Time.current, Time.current + 4.seconds, '4', event_4) - end - - expect(subject.results).to match( - calls: 4, - summary: { - "Cached" => 1, - "In a transaction" => 1, - "Role: Primary" => 2, - "Role: Replica" => 1, - "Role: Unknown" => 1 - }, - duration: '10000.00ms', - warnings: ["active-record duration: 10000.0 over 3000"], - details: contain_exactly( - a_hash_including( - start: be_a(Time), - cached: '', - transaction: '', - duration: 1000.0, - sql: 'SELECT * FROM users WHERE id = 10', - db_role: 'Role: Primary', - db_config_name: "Config name: the_db_config_name" - ), - a_hash_including( - start: be_a(Time), - cached: 'Cached', - transaction: '', - duration: 2000.0, - sql: 'SELECT * FROM users WHERE id = 10', - db_role: 'Role: Replica', - db_config_name: "Config name: the_db_config_name" - ), - a_hash_including( - start: be_a(Time), - cached: '', - transaction: 'In a transaction', - duration: 3000.0, - sql: 'UPDATE users SET admin = true WHERE id = 10', - db_role: 'Role: Primary', - db_config_name: "Config name: the_db_config_name" - ), - a_hash_including( - start: be_a(Time), - cached: '', - transaction: '', - duration: 4000.0, - sql: 'SELECT VERSION()', - db_role: 'Role: Unknown', - db_config_name: "Config name: the_db_config_name" - ) - ) - ) - end - - context 'when the GITLAB_MULTIPLE_DATABASE_METRICS env var is disabled' do - before do - stub_env('GITLAB_MULTIPLE_DATABASE_METRICS', nil) - end - - it 'does not include db_config_name field' do - ActiveSupport::Notifications.publish('sql.active_record', Time.current, Time.current + 1.second, '1', event_1) + it 'does not include db_config_name field' do + ActiveSupport::Notifications.publish('sql.active_record', Time.current, Time.current + 1.second, '1', event_1) - expect(subject.results[:details][0][:db_config_name]).to be_nil - end + expect(subject.results[:details][0][:db_config_name]).to be_nil end end end diff --git a/spec/lib/rouge/formatters/html_gitlab_spec.rb b/spec/lib/rouge/formatters/html_gitlab_spec.rb index d45c8c2a8c5..4bc9b256dce 100644 --- a/spec/lib/rouge/formatters/html_gitlab_spec.rb +++ b/spec/lib/rouge/formatters/html_gitlab_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Rouge::Formatters::HTMLGitlab do describe '#format' do - subject { described_class.format(tokens, options) } + subject { described_class.format(tokens, **options) } let(:lang) { 'ruby' } let(:lexer) { Rouge::Lexer.find_fancy(lang) } diff --git a/spec/lib/sidebars/groups/menus/scope_menu_spec.rb b/spec/lib/sidebars/groups/menus/scope_menu_spec.rb new file mode 100644 index 00000000000..4b77a09117a --- /dev/null +++ b/spec/lib/sidebars/groups/menus/scope_menu_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sidebars::Groups::Menus::ScopeMenu do + let(:group) { build(:group) } + let(:user) { group.owner } + let(:context) { Sidebars::Groups::Context.new(current_user: user, container: group) } + + describe '#extra_nav_link_html_options' do + subject { described_class.new(context).extra_nav_link_html_options } + + specify { is_expected.to match(hash_including(class: 'context-header has-tooltip', title: context.group.name)) } + end +end diff --git a/spec/lib/sidebars/projects/menus/scope_menu_spec.rb b/spec/lib/sidebars/projects/menus/scope_menu_spec.rb index 5040ef9b0ff..980ab2f7c71 100644 --- a/spec/lib/sidebars/projects/menus/scope_menu_spec.rb +++ b/spec/lib/sidebars/projects/menus/scope_menu_spec.rb @@ -12,4 +12,10 @@ RSpec.describe Sidebars::Projects::Menus::ScopeMenu do specify { is_expected.to match(hash_including(class: 'shortcuts-project rspec-project-link')) } end + + describe '#extra_nav_link_html_options' do + subject { described_class.new(context).extra_nav_link_html_options } + + specify { is_expected.to match(hash_including(class: 'context-header has-tooltip', title: context.project.name)) } + end end diff --git a/spec/migrations/20190924152703_migrate_issue_trackers_data_spec.rb b/spec/migrations/20190924152703_migrate_issue_trackers_data_spec.rb deleted file mode 100644 index dad95760306..00000000000 --- a/spec/migrations/20190924152703_migrate_issue_trackers_data_spec.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('migrate_issue_trackers_data') - -RSpec.describe MigrateIssueTrackersData do - let(:services) { table(:services) } - let(:migration_class) { Gitlab::BackgroundMigration::MigrateIssueTrackersSensitiveData } - let(:migration_name) { migration_class.to_s.demodulize } - - let(:properties) do - { - 'url' => 'http://example.com' - } - end - - let!(:jira_integration) do - services.create!(type: 'JiraService', properties: properties, category: 'issue_tracker') - end - - let!(:jira_integration_nil) do - services.create!(type: 'JiraService', properties: nil, category: 'issue_tracker') - end - - let!(:bugzilla_integration) do - services.create!(type: 'BugzillaService', properties: properties, category: 'issue_tracker') - end - - let!(:youtrack_integration) do - services.create!(type: 'YoutrackService', properties: properties, category: 'issue_tracker') - end - - let!(:youtrack_integration_empty) do - services.create!(type: 'YoutrackService', properties: '', category: 'issue_tracker') - end - - let!(:gitlab_service) do - services.create!(type: 'GitlabIssueTrackerService', properties: properties, category: 'issue_tracker') - end - - let!(:gitlab_service_empty) do - services.create!(type: 'GitlabIssueTrackerService', properties: {}, category: 'issue_tracker') - end - - let!(:other_service) do - services.create!(type: 'OtherService', properties: properties, category: 'other_category') - end - - before do - stub_const("#{described_class}::BATCH_SIZE", 2) - end - - it 'schedules background migrations at correct time' do - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(migration_name).to be_scheduled_delayed_migration(3.minutes, jira_integration.id, bugzilla_integration.id) - expect(migration_name).to be_scheduled_delayed_migration(6.minutes, youtrack_integration.id, gitlab_service.id) - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end -end diff --git a/spec/migrations/20191015154408_drop_merge_requests_require_code_owner_approval_from_projects_spec.rb b/spec/migrations/20191015154408_drop_merge_requests_require_code_owner_approval_from_projects_spec.rb deleted file mode 100644 index 731bc923910..00000000000 --- a/spec/migrations/20191015154408_drop_merge_requests_require_code_owner_approval_from_projects_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('drop_merge_requests_require_code_owner_approval_from_projects') - -RSpec.describe DropMergeRequestsRequireCodeOwnerApprovalFromProjects do - let(:projects_table) { table(:projects) } - - subject(:migration) { described_class.new } - - describe "without running the migration" do - it "project_table has a :merge_requests_require_code_owner_approval column" do - expect(projects_table.column_names) - .to include("merge_requests_require_code_owner_approval") - end - - it "project_table has a :projects_requiring_code_owner_approval index" do - expect(ActiveRecord::Base.connection.indexes(:projects).collect(&:name)) - .to include("projects_requiring_code_owner_approval") - end - end - - describe '#up' do - context "without running " - before do - migrate! - end - - it "drops the :merge_requests_require_code_owner_approval column" do - expect(projects_table.column_names) - .not_to include("merge_requests_require_code_owner_approval") - end - - it "drops the :projects_requiring_code_owner_approval index" do - expect(ActiveRecord::Base.connection.indexes(:projects).collect(&:name)) - .not_to include("projects_requiring_code_owner_approval") - end - end - - describe "#down" do - before do - migration.up - migration.down - end - - it "project_table has a :merge_requests_require_code_owner_approval column" do - expect(projects_table.column_names) - .to include("merge_requests_require_code_owner_approval") - end - - it "project_table has a :projects_requiring_code_owner_approval index" do - expect(ActiveRecord::Base.connection.indexes(:projects).collect(&:name)) - .to include("projects_requiring_code_owner_approval") - end - end -end diff --git a/spec/migrations/20191125114345_add_admin_mode_protected_path_spec.rb b/spec/migrations/20191125114345_add_admin_mode_protected_path_spec.rb deleted file mode 100644 index 222a000c134..00000000000 --- a/spec/migrations/20191125114345_add_admin_mode_protected_path_spec.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('add_admin_mode_protected_path') - -RSpec.describe AddAdminModeProtectedPath do - subject(:migration) { described_class.new } - - let(:admin_mode_endpoint) { '/admin/session' } - let(:application_settings) { table(:application_settings) } - - context 'no settings available' do - it 'makes no changes' do - expect { migrate! }.not_to change { application_settings.count } - end - end - - context 'protected_paths is null' do - before do - application_settings.create!(protected_paths: nil) - end - - it 'makes no changes' do - expect { migrate! }.not_to change { application_settings.first.protected_paths } - end - end - - it 'appends admin mode endpoint' do - application_settings.create!(protected_paths: '{a,b,c}') - - protected_paths_before = %w[a b c] - protected_paths_after = protected_paths_before.dup << admin_mode_endpoint - - expect { migrate! }.to change { application_settings.first.protected_paths }.from(protected_paths_before).to(protected_paths_after) - end - - it 'new default includes admin mode endpoint' do - settings_before = application_settings.create! - - expect(settings_before.protected_paths).not_to include(admin_mode_endpoint) - - migrate! - - application_settings.reset_column_information - settings_after = application_settings.create! - - expect(settings_after.protected_paths).to include(admin_mode_endpoint) - end -end diff --git a/spec/migrations/20191204114127_delete_legacy_triggers_spec.rb b/spec/migrations/20191204114127_delete_legacy_triggers_spec.rb deleted file mode 100644 index aba3a902888..00000000000 --- a/spec/migrations/20191204114127_delete_legacy_triggers_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('delete_legacy_triggers') - -RSpec.describe DeleteLegacyTriggers, schema: 2019_11_25_140458 do - let(:ci_trigger_table) { table(:ci_triggers) } - let(:user) { table(:users).create!(name: 'test', email: 'test@example.com', projects_limit: 1) } - - before do - @trigger_with_user = ci_trigger_table.create!(owner_id: user.id) - ci_trigger_table.create!(owner_id: nil) - ci_trigger_table.create!(owner_id: nil) - end - - it 'removes legacy triggers which has null owner_id' do - expect do - migrate! - end.to change(ci_trigger_table, :count).by(-2) - - expect(ci_trigger_table.all).to eq([@trigger_with_user]) - end -end diff --git a/spec/migrations/20210906130643_drop_temporary_columns_and_triggers_for_taggings_spec.rb b/spec/migrations/20210906130643_drop_temporary_columns_and_triggers_for_taggings_spec.rb new file mode 100644 index 00000000000..2e7ce733373 --- /dev/null +++ b/spec/migrations/20210906130643_drop_temporary_columns_and_triggers_for_taggings_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration!('drop_temporary_columns_and_triggers_for_taggings') + +RSpec.describe DropTemporaryColumnsAndTriggersForTaggings do + let(:taggings_table) { table(:taggings) } + + it 'correctly migrates up and down' do + reversible_migration do |migration| + migration.before -> { + expect(taggings_table.column_names).to include('id_convert_to_bigint') + expect(taggings_table.column_names).to include('taggable_id_convert_to_bigint') + } + + migration.after -> { + taggings_table.reset_column_information + expect(taggings_table.column_names).not_to include('id_convert_to_bigint') + expect(taggings_table.column_names).not_to include('taggable_id_convert_to_bigint') + } + end + end +end diff --git a/spec/migrations/20210907013944_cleanup_bigint_conversion_for_ci_builds_metadata_spec.rb b/spec/migrations/20210907013944_cleanup_bigint_conversion_for_ci_builds_metadata_spec.rb new file mode 100644 index 00000000000..ece5ed8251d --- /dev/null +++ b/spec/migrations/20210907013944_cleanup_bigint_conversion_for_ci_builds_metadata_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration!('cleanup_bigint_conversion_for_ci_builds_metadata') + +RSpec.describe CleanupBigintConversionForCiBuildsMetadata do + let(:ci_builds_metadata) { table(:ci_builds_metadata) } + + it 'correctly migrates up and down' do + reversible_migration do |migration| + migration.before -> { + expect(ci_builds_metadata.column_names).to include('id_convert_to_bigint') + expect(ci_builds_metadata.column_names).to include('build_id_convert_to_bigint') + } + + migration.after -> { + ci_builds_metadata.reset_column_information + expect(ci_builds_metadata.column_names).not_to include('id_convert_to_bigint') + expect(ci_builds_metadata.column_names).not_to include('build_id_convert_to_bigint') + } + end + end +end diff --git a/spec/migrations/20210910194952_update_report_type_for_existing_approval_project_rules_spec.rb b/spec/migrations/20210910194952_update_report_type_for_existing_approval_project_rules_spec.rb new file mode 100644 index 00000000000..46a6d8d92ec --- /dev/null +++ b/spec/migrations/20210910194952_update_report_type_for_existing_approval_project_rules_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration!('update_report_type_for_existing_approval_project_rules') + +RSpec.describe UpdateReportTypeForExistingApprovalProjectRules, :migration do + using RSpec::Parameterized::TableSyntax + + let(:group) { table(:namespaces).create!(name: 'user', path: 'user') } + let(:project) { table(:projects).create!(namespace_id: group.id) } + let(:approval_project_rule) { table(:approval_project_rules).create!(name: rule_name, rule_type: rule_type, project_id: project.id) } + let(:rule_type) { 2 } + let(:rule_name) { 'Vulnerability-Check' } + + context 'with rule_type set to :report_approver' do + where(:rule_name, :report_type) do + [ + ['Vulnerability-Check', 1], + ['License-Check', 2], + ['Coverage-Check', 3] + ] + end + + with_them do + context "with names associated with report type" do + it 'updates report_type' do + expect { migrate! }.to change { approval_project_rule.reload.report_type }.from(nil).to(report_type) + end + end + end + end + + context 'with rule_type set to another value (e.g., :regular)' do + let(:rule_type) { 0 } + + it 'does not update report_type' do + expect { migrate! }.not_to change { approval_project_rule.reload.report_type } + end + end + + context 'with the rule name set to another value (e.g., Test Rule)' do + let(:rule_name) { 'Test Rule'} + + it 'does not update report_type' do + expect { migrate! }.not_to change { approval_project_rule.reload.report_type } + end + end +end diff --git a/spec/migrations/20210915022415_cleanup_bigint_conversion_for_ci_builds_spec.rb b/spec/migrations/20210915022415_cleanup_bigint_conversion_for_ci_builds_spec.rb new file mode 100644 index 00000000000..ee71322433d --- /dev/null +++ b/spec/migrations/20210915022415_cleanup_bigint_conversion_for_ci_builds_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration!('cleanup_bigint_conversion_for_ci_builds') + +RSpec.describe CleanupBigintConversionForCiBuilds do + let(:ci_builds) { table(:ci_builds) } + + it 'correctly migrates up and down' do + reversible_migration do |migration| + migration.before -> { + expect(ci_builds.column_names).to include('id_convert_to_bigint') + expect(ci_builds.column_names).to include('stage_id_convert_to_bigint') + } + + migration.after -> { + ci_builds.reset_column_information + expect(ci_builds.column_names).not_to include('id_convert_to_bigint') + expect(ci_builds.column_names).not_to include('stage_id_convert_to_bigint') + } + end + end +end diff --git a/spec/migrations/20210918201050_remove_old_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb b/spec/migrations/20210918201050_remove_old_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb new file mode 100644 index 00000000000..9addaaf2551 --- /dev/null +++ b/spec/migrations/20210918201050_remove_old_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20210918201050_remove_old_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid.rb') + +def create_background_migration_jobs(ids, status, created_at) + proper_status = case status + when :pending + Gitlab::Database::BackgroundMigrationJob.statuses['pending'] + when :succeeded + Gitlab::Database::BackgroundMigrationJob.statuses['succeeded'] + else + raise ArgumentError + end + + background_migration_jobs.create!( + class_name: 'RecalculateVulnerabilitiesOccurrencesUuid', + arguments: Array(ids), + status: proper_status, + created_at: created_at + ) +end + +RSpec.describe RemoveOldPendingJobsForRecalculateVulnerabilitiesOccurrencesUuid, :migration do + let_it_be(:background_migration_jobs) { table(:background_migration_jobs) } + let_it_be(:before_target_date) { -Float::INFINITY..(DateTime.new(2021, 8, 17, 23, 59, 59)) } + let_it_be(:after_target_date) { (DateTime.new(2021, 8, 18, 0, 0, 0))..Float::INFINITY } + + context 'when old RecalculateVulnerabilitiesOccurrencesUuid jobs are pending' do + before do + create_background_migration_jobs([1, 2, 3], :succeeded, DateTime.new(2021, 5, 5, 0, 2)) + create_background_migration_jobs([4, 5, 6], :pending, DateTime.new(2021, 5, 5, 0, 4)) + + create_background_migration_jobs([1, 2, 3], :succeeded, DateTime.new(2021, 8, 18, 0, 0)) + create_background_migration_jobs([4, 5, 6], :pending, DateTime.new(2021, 8, 18, 0, 2)) + create_background_migration_jobs([7, 8, 9], :pending, DateTime.new(2021, 8, 18, 0, 4)) + end + + it 'removes old, pending jobs' do + migrate! + + expect(background_migration_jobs.where(created_at: before_target_date).count).to eq(1) + expect(background_migration_jobs.where(created_at: after_target_date).count).to eq(3) + end + end +end diff --git a/spec/migrations/20210918202855_reschedule_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb b/spec/migrations/20210918202855_reschedule_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb new file mode 100644 index 00000000000..5a2531bb63f --- /dev/null +++ b/spec/migrations/20210918202855_reschedule_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20210918202855_reschedule_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid.rb') + +RSpec.describe ReschedulePendingJobsForRecalculateVulnerabilitiesOccurrencesUuid, :migration do + let_it_be(:background_migration_jobs) { table(:background_migration_jobs) } + + context 'when RecalculateVulnerabilitiesOccurrencesUuid jobs are pending' do + before do + background_migration_jobs.create!( + class_name: 'RecalculateVulnerabilitiesOccurrencesUuid', + arguments: [1, 2, 3], + status: Gitlab::Database::BackgroundMigrationJob.statuses['pending'] + ) + background_migration_jobs.create!( + class_name: 'RecalculateVulnerabilitiesOccurrencesUuid', + arguments: [4, 5, 6], + status: Gitlab::Database::BackgroundMigrationJob.statuses['succeeded'] + ) + end + + it 'queues pending jobs' do + migrate! + + expect(BackgroundMigrationWorker.jobs.length).to eq(1) + expect(BackgroundMigrationWorker.jobs[0]['args']).to eq(['RecalculateVulnerabilitiesOccurrencesUuid', [1, 2, 3]]) + expect(BackgroundMigrationWorker.jobs[0]['at']).to be_nil + end + end +end diff --git a/spec/migrations/20210922021816_drop_int4_columns_for_ci_job_artifacts_spec.rb b/spec/migrations/20210922021816_drop_int4_columns_for_ci_job_artifacts_spec.rb new file mode 100644 index 00000000000..cf326cf0c0a --- /dev/null +++ b/spec/migrations/20210922021816_drop_int4_columns_for_ci_job_artifacts_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration!('drop_int4_columns_for_ci_job_artifacts') + +RSpec.describe DropInt4ColumnsForCiJobArtifacts do + let(:ci_job_artifacts) { table(:ci_job_artifacts) } + + it 'correctly migrates up and down' do + reversible_migration do |migration| + migration.before -> { + expect(ci_job_artifacts.column_names).to include('id_convert_to_bigint') + expect(ci_job_artifacts.column_names).to include('job_id_convert_to_bigint') + } + + migration.after -> { + ci_job_artifacts.reset_column_information + expect(ci_job_artifacts.column_names).not_to include('id_convert_to_bigint') + expect(ci_job_artifacts.column_names).not_to include('job_id_convert_to_bigint') + } + end + end +end diff --git a/spec/migrations/20210922025631_drop_int4_column_for_ci_sources_pipelines_spec.rb b/spec/migrations/20210922025631_drop_int4_column_for_ci_sources_pipelines_spec.rb new file mode 100644 index 00000000000..00b922ee4f8 --- /dev/null +++ b/spec/migrations/20210922025631_drop_int4_column_for_ci_sources_pipelines_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration!('drop_int4_column_for_ci_sources_pipelines') + +RSpec.describe DropInt4ColumnForCiSourcesPipelines do + let(:ci_sources_pipelines) { table(:ci_sources_pipelines) } + + it 'correctly migrates up and down' do + reversible_migration do |migration| + migration.before -> { + expect(ci_sources_pipelines.column_names).to include('source_job_id_convert_to_bigint') + } + + migration.after -> { + ci_sources_pipelines.reset_column_information + expect(ci_sources_pipelines.column_names).not_to include('source_job_id_convert_to_bigint') + } + end + end +end diff --git a/spec/migrations/20210922082019_drop_int4_column_for_events_spec.rb b/spec/migrations/20210922082019_drop_int4_column_for_events_spec.rb new file mode 100644 index 00000000000..412556fc283 --- /dev/null +++ b/spec/migrations/20210922082019_drop_int4_column_for_events_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration!('drop_int4_column_for_events') + +RSpec.describe DropInt4ColumnForEvents do + let(:events) { table(:events) } + + it 'correctly migrates up and down' do + reversible_migration do |migration| + migration.before -> { + expect(events.column_names).to include('id_convert_to_bigint') + } + + migration.after -> { + events.reset_column_information + expect(events.column_names).not_to include('id_convert_to_bigint') + } + end + end +end diff --git a/spec/migrations/20210922091402_drop_int4_column_for_push_event_payloads_spec.rb b/spec/migrations/20210922091402_drop_int4_column_for_push_event_payloads_spec.rb new file mode 100644 index 00000000000..2b286e3e5e0 --- /dev/null +++ b/spec/migrations/20210922091402_drop_int4_column_for_push_event_payloads_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration!('drop_int4_column_for_push_event_payloads') + +RSpec.describe DropInt4ColumnForPushEventPayloads do + let(:push_event_payloads) { table(:push_event_payloads) } + + it 'correctly migrates up and down' do + reversible_migration do |migration| + migration.before -> { + expect(push_event_payloads.column_names).to include('event_id_convert_to_bigint') + } + + migration.after -> { + push_event_payloads.reset_column_information + expect(push_event_payloads.column_names).not_to include('event_id_convert_to_bigint') + } + end + end +end diff --git a/spec/migrations/20211006060436_schedule_populate_topics_total_projects_count_cache_spec.rb b/spec/migrations/20211006060436_schedule_populate_topics_total_projects_count_cache_spec.rb new file mode 100644 index 00000000000..d07d9a71b06 --- /dev/null +++ b/spec/migrations/20211006060436_schedule_populate_topics_total_projects_count_cache_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration!('schedule_populate_topics_total_projects_count_cache') + +RSpec.describe SchedulePopulateTopicsTotalProjectsCountCache do + let(:topics) { table(:topics) } + let!(:topic_1) { topics.create!(name: 'Topic1') } + let!(:topic_2) { topics.create!(name: 'Topic2') } + let!(:topic_3) { topics.create!(name: 'Topic3') } + + describe '#up' do + before do + stub_const("#{described_class}::BATCH_SIZE", 2) + end + + it 'schedules BackfillProjectsWithCoverage background jobs', :aggregate_failures do + Sidekiq::Testing.fake! do + freeze_time do + migrate! + + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, topic_1.id, topic_2.id) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, topic_3.id, topic_3.id) + expect(BackgroundMigrationWorker.jobs.size).to eq(2) + end + end + end + end +end diff --git a/spec/migrations/add_default_and_free_plans_spec.rb b/spec/migrations/add_default_and_free_plans_spec.rb deleted file mode 100644 index 7256e4928af..00000000000 --- a/spec/migrations/add_default_and_free_plans_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('add_default_and_free_plans') - -RSpec.describe AddDefaultAndFreePlans do - describe 'migrate' do - let(:plans) { table(:plans) } - - context 'when on Gitlab.com' do - before do - expect(Gitlab).to receive(:com?) { true } - end - - it 'creates free and default plans' do - expect { migrate! }.to change { plans.count }.by 2 - - expect(plans.last(2).pluck(:name)).to eq %w[free default] - end - end - - context 'when on self-hosted' do - before do - expect(Gitlab).to receive(:com?) { false } - end - - it 'creates only a default plan' do - expect { migrate! }.to change { plans.count }.by 1 - - expect(plans.last.name).to eq 'default' - end - end - end -end diff --git a/spec/migrations/add_unique_constraint_to_approvals_user_id_and_merge_request_id_spec.rb b/spec/migrations/add_unique_constraint_to_approvals_user_id_and_merge_request_id_spec.rb deleted file mode 100644 index 795de51d387..00000000000 --- a/spec/migrations/add_unique_constraint_to_approvals_user_id_and_merge_request_id_spec.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe AddUniqueConstraintToApprovalsUserIdAndMergeRequestId do - let(:migration) { described_class.new } - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:merge_requests) { table(:merge_requests) } - let(:approvals) { table(:approvals) } - - describe '#up' do - before do - namespaces.create!(id: 1, name: 'ns', path: 'ns') - projects.create!(id: 1, namespace_id: 1) - merge_requests.create!(id: 1, target_branch: 'master', source_branch: 'feature-1', target_project_id: 1) - merge_requests.create!(id: 2, target_branch: 'master', source_branch: 'feature-2', target_project_id: 1) - end - - it 'deletes duplicate records and keeps the first one' do - first_approval = approvals.create!(id: 1, merge_request_id: 1, user_id: 1) - approvals.create!(id: 2, merge_request_id: 1, user_id: 1) - - migration.up - - expect(approvals.all.to_a).to contain_exactly(first_approval) - end - - it 'does not delete unique records' do - unique_approvals = [ - approvals.create(id: 1, merge_request_id: 1, user_id: 1), - approvals.create(id: 2, merge_request_id: 1, user_id: 2), - approvals.create(id: 3, merge_request_id: 2, user_id: 1) - ] - - migration.up - - expect(approvals.all.to_a).to contain_exactly(*unique_approvals) - end - - it 'creates unique index' do - migration.up - - expect(migration.index_exists?(:approvals, [:user_id, :merge_request_id], unique: true)).to be_truthy - end - end - - describe '#down' do - it 'removes unique index' do - migration.up - migration.down - - expect(migration.index_exists?(:approvals, [:user_id, :merge_request_id], unique: true)).to be_falsey - end - end -end diff --git a/spec/migrations/backfill_and_add_not_null_constraint_to_released_at_column_on_releases_table_spec.rb b/spec/migrations/backfill_and_add_not_null_constraint_to_released_at_column_on_releases_table_spec.rb deleted file mode 100644 index ea6599fc122..00000000000 --- a/spec/migrations/backfill_and_add_not_null_constraint_to_released_at_column_on_releases_table_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe BackfillAndAddNotNullConstraintToReleasedAtColumnOnReleasesTable do - let(:releases) { table(:releases) } - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - - subject(:migration) { described_class.new } - - it 'fills released_at with the value of created_at' do - created_at_a = Time.zone.parse('2019-02-10T08:00:00Z') - created_at_b = Time.zone.parse('2019-03-10T18:00:00Z') - namespace = namespaces.create!(name: 'foo', path: 'foo') - project = projects.create!(namespace_id: namespace.id) - release_a = releases.create!(project_id: project.id, created_at: created_at_a) - release_b = releases.create!(project_id: project.id, created_at: created_at_b) - - disable_migrations_output { migration.up } - - release_a.reload - release_b.reload - expect(release_a.released_at).to eq(created_at_a) - expect(release_b.released_at).to eq(created_at_b) - end -end diff --git a/spec/migrations/backfill_operations_feature_flags_active_spec.rb b/spec/migrations/backfill_operations_feature_flags_active_spec.rb deleted file mode 100644 index a28f648c75a..00000000000 --- a/spec/migrations/backfill_operations_feature_flags_active_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe BackfillOperationsFeatureFlagsActive do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:flags) { table(:operations_feature_flags) } - - def setup - namespace = namespaces.create!(name: 'foo', path: 'foo') - projects.create!(namespace_id: namespace.id) - end - - it 'executes successfully when there are no flags in the table' do - setup - - disable_migrations_output { migrate! } - - expect(flags.count).to eq(0) - end - - it 'updates active to true' do - project = setup - flag = flags.create!(project_id: project.id, name: 'test_flag', active: false) - - disable_migrations_output { migrate! } - - expect(flag.reload.active).to eq(true) - end - - it 'updates active to true for multiple flags' do - project = setup - flag_a = flags.create!(project_id: project.id, name: 'test_flag', active: false) - flag_b = flags.create!(project_id: project.id, name: 'other_flag', active: false) - - disable_migrations_output { migrate! } - - expect(flag_a.reload.active).to eq(true) - expect(flag_b.reload.active).to eq(true) - end - - it 'leaves active true if it is already true' do - project = setup - flag = flags.create!(project_id: project.id, name: 'test_flag', active: true) - - disable_migrations_output { migrate! } - - expect(flag.reload.active).to eq(true) - end -end diff --git a/spec/migrations/backfill_releases_table_updated_at_and_add_not_null_constraints_to_timestamps_spec.rb b/spec/migrations/backfill_releases_table_updated_at_and_add_not_null_constraints_to_timestamps_spec.rb deleted file mode 100644 index 6e8bcfc050d..00000000000 --- a/spec/migrations/backfill_releases_table_updated_at_and_add_not_null_constraints_to_timestamps_spec.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe BackfillReleasesTableUpdatedAtAndAddNotNullConstraintsToTimestamps do - let(:releases) { table(:releases) } - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - - subject(:migration) { described_class.new } - - it 'fills null updated_at rows with the value of created_at' do - created_at_a = Time.zone.parse('2014-03-11T04:30:00Z') - created_at_b = Time.zone.parse('2019-09-10T12:00:00Z') - namespace = namespaces.create!(name: 'foo', path: 'foo') - project = projects.create!(namespace_id: namespace.id) - release_a = releases.create!(project_id: project.id, - released_at: Time.zone.parse('2014-12-10T06:00:00Z'), - created_at: created_at_a) - release_b = releases.create!(project_id: project.id, - released_at: Time.zone.parse('2019-09-11T06:00:00Z'), - created_at: created_at_b) - release_a.update!(updated_at: nil) - release_b.update!(updated_at: nil) - - disable_migrations_output { migrate! } - - release_a.reload - release_b.reload - expect(release_a.updated_at).to eq(created_at_a) - expect(release_b.updated_at).to eq(created_at_b) - end - - it 'does not change updated_at columns with a value' do - created_at_a = Time.zone.parse('2014-03-11T04:30:00Z') - updated_at_a = Time.zone.parse('2015-01-16T10:00:00Z') - created_at_b = Time.zone.parse('2019-09-10T12:00:00Z') - namespace = namespaces.create!(name: 'foo', path: 'foo') - project = projects.create!(namespace_id: namespace.id) - release_a = releases.create!(project_id: project.id, - released_at: Time.zone.parse('2014-12-10T06:00:00Z'), - created_at: created_at_a, - updated_at: updated_at_a) - release_b = releases.create!(project_id: project.id, - released_at: Time.zone.parse('2019-09-11T06:00:00Z'), - created_at: created_at_b) - release_b.update!(updated_at: nil) - - disable_migrations_output { migrate! } - - release_a.reload - release_b.reload - expect(release_a.updated_at).to eq(updated_at_a) - expect(release_b.updated_at).to eq(created_at_b) - end -end diff --git a/spec/migrations/backport_enterprise_schema_spec.rb b/spec/migrations/backport_enterprise_schema_spec.rb deleted file mode 100644 index de6821001b4..00000000000 --- a/spec/migrations/backport_enterprise_schema_spec.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe BackportEnterpriseSchema, schema: 20190329085614 do - include MigrationsHelpers - - def drop_if_exists(table) - active_record_base.connection.drop_table(table) if active_record_base.connection.table_exists?(table) - end - - describe '#up' do - it 'creates new EE tables' do - migrate! - - expect(active_record_base.connection.table_exists?(:epics)).to be true - expect(active_record_base.connection.table_exists?(:geo_nodes)).to be true - end - - context 'missing EE columns' do - before do - drop_if_exists(:epics) - - active_record_base.connection.create_table "epics" do |t| - t.integer :group_id, null: false, index: true - t.integer :author_id, null: false, index: true - end - end - - after do - drop_if_exists(:epics) - end - - it 'flags an error' do - expect { migrate! }.to raise_error(/Your database is missing.*that is present for GitLab EE/) - end - end - end -end diff --git a/spec/migrations/change_outbound_local_requests_whitelist_default_spec.rb b/spec/migrations/change_outbound_local_requests_whitelist_default_spec.rb deleted file mode 100644 index 24e6f3480f9..00000000000 --- a/spec/migrations/change_outbound_local_requests_whitelist_default_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ChangeOutboundLocalRequestsWhitelistDefault do - let(:application_settings) { table(:application_settings) } - - it 'defaults to empty array' do - setting = application_settings.create! - setting_with_value = application_settings.create!(outbound_local_requests_whitelist: '{a,b}') - - expect(application_settings.where(outbound_local_requests_whitelist: nil).count).to eq(1) - - migrate! - - expect(application_settings.where(outbound_local_requests_whitelist: nil).count).to eq(0) - expect(setting.reload.outbound_local_requests_whitelist).to eq([]) - expect(setting_with_value.reload.outbound_local_requests_whitelist).to eq(%w[a b]) - end -end diff --git a/spec/migrations/change_packages_size_defaults_in_project_statistics_spec.rb b/spec/migrations/change_packages_size_defaults_in_project_statistics_spec.rb deleted file mode 100644 index 5e3118b0dea..00000000000 --- a/spec/migrations/change_packages_size_defaults_in_project_statistics_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ChangePackagesSizeDefaultsInProjectStatistics do - let(:project_statistics) { table(:project_statistics) } - let(:projects) { table(:projects) } - - it 'removes null packages_size' do - stats_to_migrate = 10 - - stats_to_migrate.times do |i| - p = projects.create!(name: "project #{i}", namespace_id: 1) - project_statistics.create!(project_id: p.id, namespace_id: p.namespace_id) - end - - expect { migrate! } - .to change { ProjectStatistics.where(packages_size: nil).count } - .from(stats_to_migrate) - .to(0) - end - - it 'defaults packages_size to 0' do - project = projects.create!(name: 'a new project', namespace_id: 2) - stat = project_statistics.create!(project_id: project.id, namespace_id: project.namespace_id) - - expect(stat.packages_size).to be_nil - - migrate! - - stat.reload - expect(stat.packages_size).to eq(0) - end -end diff --git a/spec/migrations/clean_up_noteable_id_for_notes_on_commits_spec.rb b/spec/migrations/clean_up_noteable_id_for_notes_on_commits_spec.rb deleted file mode 100644 index 8de30af13fd..00000000000 --- a/spec/migrations/clean_up_noteable_id_for_notes_on_commits_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe CleanUpNoteableIdForNotesOnCommits do - let(:notes) { table(:notes) } - - before do - notes.create!(noteable_type: 'Commit', commit_id: '3d0a182204cece4857f81c6462720e0ad1af39c9', noteable_id: 3, note: 'Test') - notes.create!(noteable_type: 'Commit', commit_id: '3d0a182204cece4857f81c6462720e0ad1af39c9', noteable_id: 3, note: 'Test') - notes.create!(noteable_type: 'Commit', commit_id: '3d0a182204cece4857f81c6462720e0ad1af39c9', noteable_id: 3, note: 'Test') - - notes.create!(noteable_type: 'Issue', noteable_id: 1, note: 'Test') - notes.create!(noteable_type: 'MergeRequest', noteable_id: 1, note: 'Test') - notes.create!(noteable_type: 'Snippet', noteable_id: 1, note: 'Test') - end - - it 'clears noteable_id for notes on commits' do - expect { migrate! }.to change { dirty_notes_on_commits.count }.from(3).to(0) - end - - it 'does not clear noteable_id for other notes' do - expect { migrate! }.not_to change { other_notes.count } - end - - def dirty_notes_on_commits - notes.where(noteable_type: 'Commit').where.not(noteable_id: nil) - end - - def other_notes - notes.where("noteable_type != 'Commit' AND noteable_id IS NOT NULL") - end -end diff --git a/spec/migrations/cleanup_legacy_artifact_migration_spec.rb b/spec/migrations/cleanup_legacy_artifact_migration_spec.rb deleted file mode 100644 index 6362965cc31..00000000000 --- a/spec/migrations/cleanup_legacy_artifact_migration_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration!('cleanup_legacy_artifact_migration') - -RSpec.describe CleanupLegacyArtifactMigration, :redis do - let(:migration) { spy('migration') } - - context 'when still legacy artifacts exist' do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:pipelines) { table(:ci_pipelines) } - let(:jobs) { table(:ci_builds) } - let(:job_artifacts) { table(:ci_job_artifacts) } - let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') } - let(:project) { projects.create!(name: 'gitlab', path: 'gitlab-ce', namespace_id: namespace.id) } - let(:pipeline) { pipelines.create!(project_id: project.id, ref: 'master', sha: 'adf43c3a') } - let(:archive_file_type) { Gitlab::BackgroundMigration::MigrateLegacyArtifacts::ARCHIVE_FILE_TYPE } - let(:metadata_file_type) { Gitlab::BackgroundMigration::MigrateLegacyArtifacts::METADATA_FILE_TYPE } - let(:local_store) { ::ObjectStorage::Store::LOCAL } - let(:remote_store) { ::ObjectStorage::Store::REMOTE } - let(:legacy_location) { Gitlab::BackgroundMigration::MigrateLegacyArtifacts::LEGACY_PATH_FILE_LOCATION } - - before do - jobs.create!(id: 1, commit_id: pipeline.id, project_id: project.id, status: :success, artifacts_file: 'archive.zip') - jobs.create!(id: 2, commit_id: pipeline.id, project_id: project.id, status: :failed, artifacts_metadata: 'metadata.gz') - jobs.create!(id: 3, commit_id: pipeline.id, project_id: project.id, status: :failed, artifacts_file: 'archive.zip', artifacts_metadata: 'metadata.gz') - jobs.create!(id: 4, commit_id: pipeline.id, project_id: project.id, status: :running) - jobs.create!(id: 5, commit_id: pipeline.id, project_id: project.id, status: :success, artifacts_file: 'archive.zip', artifacts_file_store: remote_store, artifacts_metadata: 'metadata.gz') - jobs.create!(id: 6, commit_id: pipeline.id, project_id: project.id, status: :failed, artifacts_file: 'archive.zip', artifacts_metadata: 'metadata.gz') - end - - it 'steals sidekiq jobs from MigrateLegacyArtifacts background migration' do - expect(Gitlab::BackgroundMigration).to receive(:steal).with('MigrateLegacyArtifacts') - - migrate! - end - - it 'migrates legacy artifacts to ci_job_artifacts table' do - migrate! - - expect(job_artifacts.order(:job_id, :file_type).pluck('project_id, job_id, file_type, file_store, size, expire_at, file, file_sha256, file_location')) - .to eq([[project.id, 1, archive_file_type, local_store, nil, nil, 'archive.zip', nil, legacy_location], - [project.id, 3, archive_file_type, local_store, nil, nil, 'archive.zip', nil, legacy_location], - [project.id, 3, metadata_file_type, local_store, nil, nil, 'metadata.gz', nil, legacy_location], - [project.id, 5, archive_file_type, remote_store, nil, nil, 'archive.zip', nil, legacy_location], - [project.id, 5, metadata_file_type, local_store, nil, nil, 'metadata.gz', nil, legacy_location], - [project.id, 6, archive_file_type, local_store, nil, nil, 'archive.zip', nil, legacy_location], - [project.id, 6, metadata_file_type, local_store, nil, nil, 'metadata.gz', nil, legacy_location]]) - end - end -end diff --git a/spec/migrations/drop_project_ci_cd_settings_merge_trains_enabled_spec.rb b/spec/migrations/drop_project_ci_cd_settings_merge_trains_enabled_spec.rb deleted file mode 100644 index 3093cd85ced..00000000000 --- a/spec/migrations/drop_project_ci_cd_settings_merge_trains_enabled_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe DropProjectCiCdSettingsMergeTrainsEnabled do - let!(:project_ci_cd_setting) { table(:project_ci_cd_settings) } - - it 'correctly migrates up and down' do - reversible_migration do |migration| - migration.before -> { - expect(project_ci_cd_setting.column_names).to include("merge_trains_enabled") - } - - migration.after -> { - project_ci_cd_setting.reset_column_information - expect(project_ci_cd_setting.column_names).not_to include("merge_trains_enabled") - } - end - end -end diff --git a/spec/migrations/encrypt_feature_flags_clients_tokens_spec.rb b/spec/migrations/encrypt_feature_flags_clients_tokens_spec.rb deleted file mode 100644 index 62bd0dafb8e..00000000000 --- a/spec/migrations/encrypt_feature_flags_clients_tokens_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe EncryptFeatureFlagsClientsTokens do - let(:migration) { described_class.new } - let(:feature_flags_clients) { table(:operations_feature_flags_clients) } - let(:projects) { table(:projects) } - let(:plaintext) { "secret-token" } - let(:ciphertext) { Gitlab::CryptoHelper.aes256_gcm_encrypt(plaintext, nonce: Gitlab::CryptoHelper::AES256_GCM_IV_STATIC) } - - describe '#up' do - it 'keeps plaintext token the same and populates token_encrypted if not present' do - project = projects.create!(id: 123, name: 'gitlab1', path: 'gitlab1', namespace_id: 123) - feature_flags_client = feature_flags_clients.create!(project_id: project.id, token: plaintext) - - migration.up - - expect(feature_flags_client.reload.token).to eq(plaintext) - expect(feature_flags_client.reload.token_encrypted).to eq(ciphertext) - end - end - - describe '#down' do - it 'decrypts encrypted token and saves it' do - project = projects.create!(id: 123, name: 'gitlab1', path: 'gitlab1', namespace_id: 123) - feature_flags_client = feature_flags_clients.create!(project_id: project.id, token_encrypted: ciphertext) - - migration.down - - expect(feature_flags_client.reload.token).to eq(plaintext) - expect(feature_flags_client.reload.token_encrypted).to eq(ciphertext) - end - end -end diff --git a/spec/migrations/encrypt_plaintext_attributes_on_application_settings_spec.rb b/spec/migrations/encrypt_plaintext_attributes_on_application_settings_spec.rb deleted file mode 100644 index 2e233816b8b..00000000000 --- a/spec/migrations/encrypt_plaintext_attributes_on_application_settings_spec.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe EncryptPlaintextAttributesOnApplicationSettings do - let(:migration) { described_class.new } - let(:application_settings) { table(:application_settings) } - let(:plaintext) { 'secret-token' } - - plaintext_attributes = %w[ - akismet_api_key - elasticsearch_aws_secret_access_key - recaptcha_private_key - recaptcha_site_key - slack_app_secret - slack_app_verification_token - ].freeze - - describe '#up' do - it 'encrypts token and saves it' do - application_setting = application_settings.create! - application_setting.update_columns( - plaintext_attributes.each_with_object({}) do |plaintext_attribute, attributes| - attributes[plaintext_attribute] = plaintext - end - ) - - migration.up - - application_setting.reload - plaintext_attributes.each do |plaintext_attribute| - expect(application_setting[plaintext_attribute]).not_to be_nil - expect(application_setting["encrypted_#{plaintext_attribute}"]).not_to be_nil - expect(application_setting["encrypted_#{plaintext_attribute}_iv"]).not_to be_nil - end - end - end - - describe '#down' do - it 'decrypts encrypted token and saves it' do - application_setting = application_settings.create!( - plaintext_attributes.each_with_object({}) do |plaintext_attribute, attributes| - attributes[plaintext_attribute] = plaintext - end - ) - - migration.down - - application_setting.reload - plaintext_attributes.each do |plaintext_attribute| - expect(application_setting[plaintext_attribute]).to eq(plaintext) - expect(application_setting["encrypted_#{plaintext_attribute}"]).to be_nil - expect(application_setting["encrypted_#{plaintext_attribute}_iv"]).to be_nil - end - end - end -end diff --git a/spec/migrations/enqueue_reset_merge_status_second_run_spec.rb b/spec/migrations/enqueue_reset_merge_status_second_run_spec.rb deleted file mode 100644 index 49698f60964..00000000000 --- a/spec/migrations/enqueue_reset_merge_status_second_run_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe EnqueueResetMergeStatusSecondRun do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') } - let(:project) { projects.create!(namespace_id: namespace.id, name: 'foo') } - let(:merge_requests) { table(:merge_requests) } - - def create_merge_request(id, extra_params = {}) - params = { - id: id, - target_project_id: project.id, - target_branch: 'master', - source_project_id: project.id, - source_branch: 'mr name', - title: "mr name#{id}" - }.merge(extra_params) - - merge_requests.create!(params) - end - - it 'correctly schedules background migrations' do - create_merge_request(1, state: 'opened', merge_status: 'can_be_merged') - create_merge_request(2, state: 'opened', merge_status: 'can_be_merged') - create_merge_request(3, state: 'opened', merge_status: 'can_be_merged') - create_merge_request(4, state: 'merged', merge_status: 'can_be_merged') - create_merge_request(5, state: 'opened', merge_status: 'unchecked') - - stub_const("#{described_class.name}::BATCH_SIZE", 2) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(5.minutes, 1, 2) - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(10.minutes, 3, 4) - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(15.minutes, 5, 5) - - expect(BackgroundMigrationWorker.jobs.size).to eq(3) - end - end - end -end diff --git a/spec/migrations/enqueue_reset_merge_status_spec.rb b/spec/migrations/enqueue_reset_merge_status_spec.rb deleted file mode 100644 index d62c99b80bc..00000000000 --- a/spec/migrations/enqueue_reset_merge_status_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe EnqueueResetMergeStatus do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') } - let(:project) { projects.create!(namespace_id: namespace.id, name: 'foo') } - let(:merge_requests) { table(:merge_requests) } - - def create_merge_request(id, extra_params = {}) - params = { - id: id, - target_project_id: project.id, - target_branch: 'master', - source_project_id: project.id, - source_branch: 'mr name', - title: "mr name#{id}" - }.merge(extra_params) - - merge_requests.create!(params) - end - - it 'correctly schedules background migrations' do - create_merge_request(1, state: 'opened', merge_status: 'can_be_merged') - create_merge_request(2, state: 'opened', merge_status: 'can_be_merged') - create_merge_request(3, state: 'opened', merge_status: 'can_be_merged') - create_merge_request(4, state: 'merged', merge_status: 'can_be_merged') - create_merge_request(5, state: 'opened', merge_status: 'unchecked') - - stub_const("#{described_class.name}::BATCH_SIZE", 2) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(5.minutes, 1, 2) - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(10.minutes, 3, 4) - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(15.minutes, 5, 5) - - expect(BackgroundMigrationWorker.jobs.size).to eq(3) - end - end - end -end diff --git a/spec/migrations/fill_productivity_analytics_start_date_spec.rb b/spec/migrations/fill_productivity_analytics_start_date_spec.rb deleted file mode 100644 index b348067a752..00000000000 --- a/spec/migrations/fill_productivity_analytics_start_date_spec.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe FillProductivityAnalyticsStartDate do - let(:settings_table) { table('application_settings') } - let(:metrics_table) { table('merge_request_metrics') } - - before do - settings_table.create! - end - - context 'with NO productivity analytics data available' do - it 'sets start_date to NOW' do - expect { migrate! }.to change { - settings_table.first&.productivity_analytics_start_date - }.to(be_like_time(Time.now)) - end - end - - context 'with productivity analytics data available' do - before do - ActiveRecord::Base.transaction do - ActiveRecord::Base.connection.execute('ALTER TABLE merge_request_metrics DISABLE TRIGGER ALL') - metrics_table.create!(merged_at: Time.parse('2019-09-09'), commits_count: nil, merge_request_id: 3) - metrics_table.create!(merged_at: Time.parse('2019-10-10'), commits_count: 5, merge_request_id: 1) - metrics_table.create!(merged_at: Time.parse('2019-11-11'), commits_count: 10, merge_request_id: 2) - ActiveRecord::Base.connection.execute('ALTER TABLE merge_request_metrics ENABLE TRIGGER ALL') - end - end - - it 'set start_date to earliest merged_at value with PA data available' do - expect { migrate! }.to change { - settings_table.first&.productivity_analytics_start_date - }.to(be_like_time(Time.parse('2019-10-10'))) - end - end -end diff --git a/spec/migrations/fix_max_pages_size_spec.rb b/spec/migrations/fix_max_pages_size_spec.rb deleted file mode 100644 index 97cf026df5c..00000000000 --- a/spec/migrations/fix_max_pages_size_spec.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe FixMaxPagesSize do - let(:application_settings) { table(:application_settings) } - let!(:default_setting) { application_settings.create! } - let!(:max_possible_setting) { application_settings.create!(max_pages_size: described_class::MAX_SIZE) } - let!(:higher_than_maximum_setting) { application_settings.create!(max_pages_size: described_class::MAX_SIZE + 1) } - - it 'correctly updates settings only if needed' do - migrate! - - expect(default_setting.reload.max_pages_size).to eq(100) - expect(max_possible_setting.reload.max_pages_size).to eq(described_class::MAX_SIZE) - expect(higher_than_maximum_setting.reload.max_pages_size).to eq(described_class::MAX_SIZE) - end -end diff --git a/spec/migrations/fix_null_type_labels_spec.rb b/spec/migrations/fix_null_type_labels_spec.rb deleted file mode 100644 index 4f902b92393..00000000000 --- a/spec/migrations/fix_null_type_labels_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe FixNullTypeLabels do - let(:migration) { described_class.new } - let(:projects) { table(:projects) } - let(:namespaces) { table(:namespaces) } - let(:labels) { table(:labels) } - - before do - group = namespaces.create!(name: 'labels-test-project', path: 'labels-test-project', type: 'Group') - project = projects.create!(namespace_id: group.id, name: 'labels-test-group', path: 'labels-test-group') - - @template_label = labels.create!(title: 'template', template: true) - @project_label = labels.create!(title: 'project label', project_id: project.id, type: 'ProjectLabel') - @group_label = labels.create!(title: 'group_label', group_id: group.id, type: 'GroupLabel') - @broken_label_1 = labels.create!(title: 'broken 1', project_id: project.id) - @broken_label_2 = labels.create!(title: 'broken 2', project_id: project.id) - end - - describe '#up' do - it 'fix labels with type missing' do - migration.up - - # Labels that requires type change - expect(@broken_label_1.reload.type).to eq('ProjectLabel') - expect(@broken_label_2.reload.type).to eq('ProjectLabel') - # Labels out of scope - expect(@template_label.reload.type).to be_nil - expect(@project_label.reload.type).to eq('ProjectLabel') - expect(@group_label.reload.type).to eq('GroupLabel') - end - end -end diff --git a/spec/migrations/fix_pool_repository_source_project_id_spec.rb b/spec/migrations/fix_pool_repository_source_project_id_spec.rb deleted file mode 100644 index 2ee4c458c3c..00000000000 --- a/spec/migrations/fix_pool_repository_source_project_id_spec.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe FixPoolRepositorySourceProjectId do - let(:projects) { table(:projects) } - let(:pool_repositories) { table(:pool_repositories) } - let(:shards) { table(:shards) } - - it 'fills in source_project_ids' do - shard = shards.create!(name: 'default') - - # gitaly is a project with a pool repository that has a source_project_id - gitaly = projects.create!(name: 'gitaly', path: 'gitlab-org/gitaly', namespace_id: 1) - pool_repository = pool_repositories.create!(shard_id: shard.id, source_project_id: gitaly.id) - gitaly.update_column(:pool_repository_id, pool_repository.id) - - # gitlab is a project with a pool repository that's missing a source_project_id - pool_repository_without_source_project = pool_repositories.create!(shard_id: shard.id, source_project_id: nil) - gitlab = projects.create!(name: 'gitlab', path: 'gitlab-org/gitlab-ce', namespace_id: 1, pool_repository_id: pool_repository_without_source_project.id) - projects.create!(name: 'gitlab-fork-1', path: 'my-org-1/gitlab-ce', namespace_id: 1, pool_repository_id: pool_repository_without_source_project.id) - - migrate! - - expect(pool_repositories.find(pool_repository_without_source_project.id).source_project_id).to eq(gitlab.id) - expect(pool_repositories.find(pool_repository.id).source_project_id).to eq(gitaly.id) - end -end diff --git a/spec/migrations/fix_wrong_pages_access_level_spec.rb b/spec/migrations/fix_wrong_pages_access_level_spec.rb deleted file mode 100644 index 00a620b4426..00000000000 --- a/spec/migrations/fix_wrong_pages_access_level_spec.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe FixWrongPagesAccessLevel, :sidekiq_might_not_need_inline, schema: 20190628185004 do - using RSpec::Parameterized::TableSyntax - - let(:migration_class) { described_class::MIGRATION } - let(:migration_name) { migration_class.to_s.demodulize } - - project_class = ::Gitlab::BackgroundMigration::FixPagesAccessLevel::Project - feature_class = ::Gitlab::BackgroundMigration::FixPagesAccessLevel::ProjectFeature - - let(:namespaces_table) { table(:namespaces) } - let(:projects_table) { table(:projects) } - let(:features_table) { table(:project_features) } - - let(:subgroup) do - root_group = namespaces_table.create!(path: "group", name: "group") - namespaces_table.create!(path: "subgroup", name: "group", parent_id: root_group.id) - end - - def create_project_feature(path, project_visibility, pages_access_level) - project = projects_table.create!(path: path, visibility_level: project_visibility, - namespace_id: subgroup.id) - features_table.create!(project_id: project.id, pages_access_level: pages_access_level) - end - - it 'correctly schedules background migrations' do - Sidekiq::Testing.fake! do - freeze_time do - first_id = create_project_feature("project1", project_class::PRIVATE, feature_class::PRIVATE).id - last_id = create_project_feature("project2", project_class::PRIVATE, feature_class::PUBLIC).id - - migrate! - - expect(migration_name).to be_scheduled_delayed_migration(2.minutes, first_id, last_id) - expect(BackgroundMigrationWorker.jobs.size).to eq(1) - end - end - end - - def expect_migration - expect do - perform_enqueued_jobs do - migrate! - end - end - end - - where(:project_visibility, :pages_access_level, :access_control_is_enabled, - :pages_deployed, :resulting_pages_access_level) do - # update settings for public projects regardless of access_control being enabled - project_class::PUBLIC | feature_class::PUBLIC | true | true | feature_class::ENABLED - project_class::PUBLIC | feature_class::PUBLIC | false | true | feature_class::ENABLED - # don't update public level for private and internal projects - project_class::PRIVATE | feature_class::PUBLIC | true | true | feature_class::PUBLIC - project_class::INTERNAL | feature_class::PUBLIC | true | true | feature_class::PUBLIC - - # if access control is disabled but pages are deployed we make them public - project_class::INTERNAL | feature_class::ENABLED | false | true | feature_class::PUBLIC - # don't change anything if one of the conditions is not satisfied - project_class::INTERNAL | feature_class::ENABLED | true | true | feature_class::ENABLED - project_class::INTERNAL | feature_class::ENABLED | true | false | feature_class::ENABLED - - # private projects - # if access control is enabled update pages_access_level to private regardless of deployment - project_class::PRIVATE | feature_class::ENABLED | true | true | feature_class::PRIVATE - project_class::PRIVATE | feature_class::ENABLED | true | false | feature_class::PRIVATE - # if access control is disabled and pages are deployed update pages_access_level to public - project_class::PRIVATE | feature_class::ENABLED | false | true | feature_class::PUBLIC - # if access control is disabled but pages aren't deployed update pages_access_level to private - project_class::PRIVATE | feature_class::ENABLED | false | false | feature_class::PRIVATE - end - - with_them do - let!(:project_feature) do - create_project_feature("projectpath", project_visibility, pages_access_level) - end - - before do - tested_path = File.join(Settings.pages.path, "group/subgroup/projectpath", "public") - allow(Dir).to receive(:exist?).with(tested_path).and_return(pages_deployed) - - stub_pages_setting(access_control: access_control_is_enabled) - end - - it "sets proper pages_access_level" do - expect(project_feature.reload.pages_access_level).to eq(pages_access_level) - - perform_enqueued_jobs do - migrate! - end - - expect(project_feature.reload.pages_access_level).to eq(resulting_pages_access_level) - end - end -end diff --git a/spec/migrations/generate_lets_encrypt_private_key_spec.rb b/spec/migrations/generate_lets_encrypt_private_key_spec.rb deleted file mode 100644 index 8525a7bbd1c..00000000000 --- a/spec/migrations/generate_lets_encrypt_private_key_spec.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe GenerateLetsEncryptPrivateKey do - describe '#up' do - it 'does not fail' do - expect do - described_class.new.up - end.not_to raise_error - end - end -end diff --git a/spec/migrations/insert_project_hooks_plan_limits_spec.rb b/spec/migrations/insert_project_hooks_plan_limits_spec.rb deleted file mode 100644 index 365dd679d76..00000000000 --- a/spec/migrations/insert_project_hooks_plan_limits_spec.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe InsertProjectHooksPlanLimits do - let(:migration) { described_class.new } - let(:plans) { table(:plans) } - let(:plan_limits) { table(:plan_limits) } - - before do - plans.create!(id: 34, name: 'free') - plans.create!(id: 2, name: 'bronze') - plans.create!(id: 3, name: 'silver') - plans.create!(id: 4, name: 'gold') - plan_limits.create!(plan_id: 34, ci_active_jobs: 5) - end - - context 'when on Gitlab.com' do - before do - expect(Gitlab).to receive(:com?).at_most(:twice).and_return(true) - end - - describe '#up' do - it 'updates the project_hooks plan limits' do - migration.up - - expect(plan_limits.pluck(:plan_id, :project_hooks, :ci_active_jobs)) - .to match_array([[34, 10, 5], [2, 20, 0], [3, 30, 0], [4, 100, 0]]) - end - end - - describe '#down' do - it 'updates the project_hooks plan limits to 0' do - migration.up - migration.down - - expect(plan_limits.pluck(:plan_id, :project_hooks, :ci_active_jobs)) - .to match_array([[34, 0, 5], [2, 0, 0], [3, 0, 0], [4, 0, 0]]) - end - end - end - - context 'when on self-hosted' do - before do - expect(Gitlab).to receive(:com?).and_return(false) - end - - describe '#up' do - it 'does not update the plan limits' do - migration.up - - expect(plan_limits.pluck(:plan_id, :project_hooks, :ci_active_jobs)) - .to match_array([[34, 0, 5]]) - end - end - - describe '#down' do - it 'does not update the plan limits' do - migration.down - - expect(plan_limits.pluck(:plan_id, :project_hooks, :ci_active_jobs)) - .to match_array([[34, 0, 5]]) - end - end - end -end diff --git a/spec/migrations/migrate_auto_dev_ops_domain_to_cluster_domain_spec.rb b/spec/migrations/migrate_auto_dev_ops_domain_to_cluster_domain_spec.rb deleted file mode 100644 index a836fb4bfb9..00000000000 --- a/spec/migrations/migrate_auto_dev_ops_domain_to_cluster_domain_spec.rb +++ /dev/null @@ -1,114 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe MigrateAutoDevOpsDomainToClusterDomain do - include MigrationHelpers::ClusterHelpers - - let(:migration) { described_class.new } - let(:project_auto_devops_table) { table(:project_auto_devops) } - let(:clusters_table) { table(:clusters) } - let(:cluster_projects_table) { table(:cluster_projects) } - - # Following lets are needed by MigrationHelpers::ClusterHelpers - let(:cluster_kubernetes_namespaces_table) { table(:clusters_kubernetes_namespaces) } - let(:projects_table) { table(:projects) } - let(:namespaces_table) { table(:namespaces) } - let(:provider_gcp_table) { table(:cluster_providers_gcp) } - let(:platform_kubernetes_table) { table(:cluster_platforms_kubernetes) } - - before do - setup_cluster_projects_with_domain(quantity: 20, domain: domain) - end - - context 'with ProjectAutoDevOps with no domain' do - let(:domain) { nil } - - it 'does not update cluster project' do - migrate! - - expect(clusters_without_domain.count).to eq(clusters_table.count) - end - end - - context 'with ProjectAutoDevOps with domain' do - let(:domain) { 'example-domain.com' } - - it 'updates all cluster projects' do - migrate! - - expect(clusters_with_domain.count).to eq(clusters_table.count) - end - end - - context 'when only some ProjectAutoDevOps have domain set' do - let(:domain) { 'example-domain.com' } - - before do - setup_cluster_projects_with_domain(quantity: 25, domain: nil) - end - - it 'only updates specific cluster projects' do - migrate! - - expect(clusters_with_domain.count).to eq(20) - - project_auto_devops_with_domain.each do |project_auto_devops| - cluster_project = find_cluster_project(project_auto_devops.project_id) - cluster = find_cluster(cluster_project.cluster_id) - - expect(cluster.domain).to be_present - end - - expect(clusters_without_domain.count).to eq(25) - - project_auto_devops_without_domain.each do |project_auto_devops| - cluster_project = find_cluster_project(project_auto_devops.project_id) - cluster = find_cluster(cluster_project.cluster_id) - - expect(cluster.domain).not_to be_present - end - end - end - - def setup_cluster_projects_with_domain(quantity:, domain:) - create_cluster_project_list(quantity) - - cluster_projects = cluster_projects_table.last(quantity) - - cluster_projects.each do |cluster_project| - specific_domain = "#{cluster_project.id}-#{domain}" if domain - - project_auto_devops_table.create!( - project_id: cluster_project.project_id, - enabled: true, - domain: specific_domain - ) - end - end - - def find_cluster_project(project_id) - cluster_projects_table.find_by(project_id: project_id) - end - - def find_cluster(cluster_id) - clusters_table.find_by(id: cluster_id) - end - - def project_auto_devops_with_domain - project_auto_devops_table.where.not("domain IS NULL OR domain = ''") - end - - def project_auto_devops_without_domain - project_auto_devops_table.where("domain IS NULL OR domain = ''") - end - - def clusters_with_domain - clusters_table.where.not("domain IS NULL OR domain = ''") - end - - def clusters_without_domain - clusters_table.where("domain IS NULL OR domain = ''") - end -end diff --git a/spec/migrations/migrate_code_owner_approval_status_to_protected_branches_in_batches_spec.rb b/spec/migrations/migrate_code_owner_approval_status_to_protected_branches_in_batches_spec.rb deleted file mode 100644 index 121ff3d6622..00000000000 --- a/spec/migrations/migrate_code_owner_approval_status_to_protected_branches_in_batches_spec.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe MigrateCodeOwnerApprovalStatusToProtectedBranchesInBatches do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:protected_branches) { table(:protected_branches) } - - let(:namespace) do - namespaces.create!( - path: 'gitlab-instance-administrators', - name: 'GitLab Instance Administrators' - ) - end - - let(:project) do - projects.create!( - namespace_id: namespace.id, - name: 'GitLab Instance Administration' - ) - end - - let!(:protected_branch_1) do - protected_branches.create!( - name: "branch name", - project_id: project.id - ) - end - - describe '#up' do - context "when there's no projects needing approval" do - it "doesn't change any protected branch records" do - expect { migrate! } - .not_to change { ProtectedBranch.where(code_owner_approval_required: true).count } - end - end - - context "when there's a project needing approval" do - let!(:project_needing_approval) do - projects.create!( - namespace_id: namespace.id, - name: 'GitLab Instance Administration', - merge_requests_require_code_owner_approval: true - ) - end - - let!(:protected_branch_2) do - protected_branches.create!( - name: "branch name", - project_id: project_needing_approval.id - ) - end - - it "changes N protected branch records" do - expect { migrate! } - .to change { ProtectedBranch.where(code_owner_approval_required: true).count } - .by(1) - end - end - end -end diff --git a/spec/migrations/migrate_discussion_id_on_promoted_epics_spec.rb b/spec/migrations/migrate_discussion_id_on_promoted_epics_spec.rb deleted file mode 100644 index e42baab9927..00000000000 --- a/spec/migrations/migrate_discussion_id_on_promoted_epics_spec.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe MigrateDiscussionIdOnPromotedEpics do - let(:migration_class) { described_class::MIGRATION } - let(:migration_name) { migration_class.to_s.demodulize } - - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:users) { table(:users) } - let(:issues) { table(:issues) } - let(:epics) { table(:epics) } - let(:notes) { table(:notes) } - let(:system_note_metadata) { table(:system_note_metadata) } - - let(:user) { users.create!(email: 'test@example.com', projects_limit: 100, username: 'test') } - let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') } - - def create_promotion_note(model, id) - note = create_note(model, id, { system: true, - note: 'promoted from issue XXX' }) - system_note_metadata.create!(note_id: note.id, action: 'moved') - end - - def create_epic - epics.create!(author_id: user.id, iid: epics.maximum(:iid).to_i + 1, - group_id: namespace.id, - title: 'Epic with discussion', - title_html: 'Epic with discussion') - end - - def create_note(model, id, extra_params = {}) - params = { - note: 'note', - noteable_id: model.id, - noteable_type: model.class.name, - discussion_id: id - }.merge(extra_params) - - notes.create!(params) - end - - context 'with promoted epic' do - let(:epic1) { create_epic } - let!(:note1) { create_promotion_note(epic1, 'id1') } - - it 'correctly schedules background migrations in batches' do - create_note(epic1, 'id2') - create_note(epic1, 'id3') - - stub_const("#{described_class.name}::BATCH_SIZE", 2) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(migration_name).to be_scheduled_delayed_migration(2.minutes, %w(id1 id2)) - expect(migration_name).to be_scheduled_delayed_migration(4.minutes, %w(id3)) - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end - - it 'schedules only promoted epics' do - issue = issues.create!(description: 'first', state: 'opened') - create_promotion_note(issue, 'id2') - create_note(create_epic, 'id3') - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(migration_name).to be_scheduled_delayed_migration(2.minutes, %w(id1)) - expect(BackgroundMigrationWorker.jobs.size).to eq(1) - end - end - end - end -end diff --git a/spec/migrations/migrate_k8s_service_integration_spec.rb b/spec/migrations/migrate_k8s_service_integration_spec.rb deleted file mode 100644 index ba6071b72e4..00000000000 --- a/spec/migrations/migrate_k8s_service_integration_spec.rb +++ /dev/null @@ -1,162 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe MigrateK8sServiceIntegration do - context 'template service' do - context 'with namespace' do - let!(:service) do - MigrateK8sServiceIntegration::Service.create!( - active: true, - template: true, - category: 'deployment', - type: 'KubernetesService', - properties: "{\"namespace\":\"prod\",\"api_url\":\"https://sample.kubernetes.com\",\"ca_pem\":\"ca_pem-sample\",\"token\":\"token-sample\"}" - ) - end - - let(:cluster) { MigrateK8sServiceIntegration::Cluster.instance_type.last! } - let(:platform) { cluster.platform_kubernetes } - - it 'migrates the KubernetesService template to Platform::Kubernetes' do - expect { migrate! }.to change { MigrateK8sServiceIntegration::Cluster.count }.by(1) - - expect(cluster).to be_enabled - expect(cluster).to be_user - expect(cluster).not_to be_managed - expect(cluster.environment_scope).to eq('*') - expect(platform.api_url).to eq('https://sample.kubernetes.com') - expect(platform.ca_cert).to eq('ca_pem-sample') - expect(platform.namespace).to eq('prod') - expect(platform.token).to eq('token-sample') - end - end - - context 'without namespace' do - let!(:service) do - MigrateK8sServiceIntegration::Service.create!( - active: true, - template: true, - category: 'deployment', - type: 'KubernetesService', - properties: "{\"namespace\":\"\",\"api_url\":\"https://sample.kubernetes.com\",\"ca_pem\":\"ca_pem-sample\",\"token\":\"token-sample\"}" - ) - end - - let(:cluster) { MigrateK8sServiceIntegration::Cluster.instance_type.last! } - let(:platform) { cluster.platform_kubernetes } - - it 'migrates the KubernetesService template to Platform::Kubernetes' do - expect { migrate! }.to change { MigrateK8sServiceIntegration::Cluster.count }.by(1) - - expect(cluster).to be_enabled - expect(cluster).to be_user - expect(cluster).not_to be_managed - expect(cluster.environment_scope).to eq('*') - expect(platform.api_url).to eq('https://sample.kubernetes.com') - expect(platform.ca_cert).to eq('ca_pem-sample') - expect(platform.namespace).to be_nil - expect(platform.token).to eq('token-sample') - end - end - - context 'with nullified parameters' do - let!(:service) do - MigrateK8sServiceIntegration::Service.create!( - active: true, - template: true, - category: 'deployment', - type: 'KubernetesService', - properties: "{}" - ) - end - - it 'does not migrate the KubernetesService' do - expect { migrate! }.not_to change { MigrateK8sServiceIntegration::Cluster.count } - end - end - - context 'when disabled' do - let!(:service) do - MigrateK8sServiceIntegration::Service.create!( - active: false, - template: true, - category: 'deployment', - type: 'KubernetesService', - properties: "{\"namespace\":\"prod\",\"api_url\":\"https://sample.kubernetes.com\",\"ca_pem\":\"ca_pem-sample\",\"token\":\"token-sample\"}" - ) - end - - let(:cluster) { MigrateK8sServiceIntegration::Cluster.instance_type.last! } - let(:platform) { cluster.platform_kubernetes } - - it 'migrates the KubernetesService template to Platform::Kubernetes' do - expect { migrate! }.to change { MigrateK8sServiceIntegration::Cluster.count }.by(1) - - expect(cluster).not_to be_enabled - expect(cluster).to be_user - expect(cluster).not_to be_managed - expect(cluster.environment_scope).to eq('*') - expect(platform.api_url).to eq('https://sample.kubernetes.com') - expect(platform.ca_cert).to eq('ca_pem-sample') - expect(platform.namespace).to eq('prod') - expect(platform.token).to eq('token-sample') - end - end - - context 'when an instance cluster already exists' do - let!(:service) do - MigrateK8sServiceIntegration::Service.create!( - active: true, - template: true, - category: 'deployment', - type: 'KubernetesService', - properties: "{\"namespace\":\"prod\",\"api_url\":\"https://sample.kubernetes.com\",\"ca_pem\":\"ca_pem-sample\",\"token\":\"token-sample\"}" - ) - end - - let!(:existing_cluster) do - MigrateK8sServiceIntegration::Cluster.create!( - name: 'test-cluster', - cluster_type: :instance_type, - managed: true, - provider_type: :user, - platform_type: :kubernetes - ) - end - - let(:new_cluster) { MigrateK8sServiceIntegration::Cluster.instance_type.last! } - let(:platform) { new_cluster.platform_kubernetes } - - it 'migrates the KubernetesService template to disabled Platform::Kubernetes' do - expect { migrate! }.to change { MigrateK8sServiceIntegration::Cluster.count }.by(1) - - expect(new_cluster).not_to be_enabled - expect(new_cluster).to be_user - expect(new_cluster).not_to be_managed - expect(new_cluster.environment_scope).to eq('*') - expect(platform.api_url).to eq('https://sample.kubernetes.com') - expect(platform.ca_cert).to eq('ca_pem-sample') - expect(platform.namespace).to eq('prod') - expect(platform.token).to eq('token-sample') - end - end - end - - context 'non-template service' do - let!(:service) do - MigrateK8sServiceIntegration::Service.create!( - active: true, - template: false, - category: 'deployment', - type: 'KubernetesService', - properties: "{\"namespace\":\"prod\",\"api_url\":\"https://sample.kubernetes.com\",\"ca_pem\":\"ca_pem-sample\",\"token\":\"token-sample\"}" - ) - end - - it 'does not migrate the KubernetesService' do - expect { migrate! }.not_to change { MigrateK8sServiceIntegration::Cluster.count } - end - end -end diff --git a/spec/migrations/migrate_legacy_managed_clusters_to_unmanaged_spec.rb b/spec/migrations/migrate_legacy_managed_clusters_to_unmanaged_spec.rb deleted file mode 100644 index 3d8685c7619..00000000000 --- a/spec/migrations/migrate_legacy_managed_clusters_to_unmanaged_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe MigrateLegacyManagedClustersToUnmanaged do - let(:cluster_type) { 'project_type' } - let(:created_at) { 1.hour.ago } - - let!(:cluster) do - table(:clusters).create!( - name: 'cluster', - cluster_type: described_class::Cluster.cluster_types[cluster_type], - managed: true, - created_at: created_at - ) - end - - it 'marks the cluster as unmanaged' do - migrate! - expect(cluster.reload).not_to be_managed - end - - context 'cluster is not project type' do - let(:cluster_type) { 'group_type' } - - it 'does not update the cluster' do - migrate! - expect(cluster.reload).to be_managed - end - end - - context 'cluster has a kubernetes namespace associated' do - before do - table(:clusters_kubernetes_namespaces).create!( - cluster_id: cluster.id, - namespace: 'namespace' - ) - end - - it 'does not update the cluster' do - migrate! - expect(cluster.reload).to be_managed - end - end - - context 'cluster was recently created' do - let(:created_at) { 2.minutes.ago } - - it 'does not update the cluster' do - migrate! - expect(cluster.reload).to be_managed - end - end -end diff --git a/spec/migrations/migrate_managed_clusters_with_no_token_to_unmanaged_spec.rb b/spec/migrations/migrate_managed_clusters_with_no_token_to_unmanaged_spec.rb deleted file mode 100644 index b753b84ae55..00000000000 --- a/spec/migrations/migrate_managed_clusters_with_no_token_to_unmanaged_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe MigrateManagedClustersWithNoTokenToUnmanaged do - let(:cluster_type) { 'project_type' } - let(:created_at) { Date.new(2018, 11, 1).midnight } - - let!(:cluster) do - table(:clusters).create!( - name: 'cluster', - cluster_type: described_class::Cluster.cluster_types[cluster_type], - managed: true, - created_at: created_at - ) - end - - let!(:kubernetes_namespace) do - table(:clusters_kubernetes_namespaces).create!( - cluster_id: cluster.id, - namespace: 'namespace' - ) - end - - it 'marks the cluster as unmanaged' do - migrate! - expect(cluster.reload).not_to be_managed - end - - context 'cluster is not project type' do - let(:cluster_type) { 'group_type' } - - it 'does not update the cluster' do - migrate! - expect(cluster.reload).to be_managed - end - end - - context 'kubernetes namespace has a service account token' do - before do - kubernetes_namespace.update!(encrypted_service_account_token: "TOKEN") - end - - it 'does not update the cluster' do - migrate! - expect(cluster.reload).to be_managed - end - end - - context 'cluster was created after the cutoff' do - let(:created_at) { Date.new(2019, 1, 1).midnight } - - it 'does not update the cluster' do - migrate! - expect(cluster.reload).to be_managed - end - end -end diff --git a/spec/migrations/migrate_ops_feature_flags_scopes_target_user_ids_spec.rb b/spec/migrations/migrate_ops_feature_flags_scopes_target_user_ids_spec.rb deleted file mode 100644 index 5caf03992dd..00000000000 --- a/spec/migrations/migrate_ops_feature_flags_scopes_target_user_ids_spec.rb +++ /dev/null @@ -1,135 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe MigrateOpsFeatureFlagsScopesTargetUserIds do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:flags) { table(:operations_feature_flags) } - let(:scopes) { table(:operations_feature_flag_scopes) } - - def setup - namespace = namespaces.create!(name: 'foo', path: 'foo') - project = projects.create!(namespace_id: namespace.id) - flags.create!(project_id: project.id, active: true, name: 'test_flag') - end - - it 'migrates successfully when there are no scopes in the database' do - setup - - disable_migrations_output { migrate! } - - expect(scopes.count).to eq(0) - end - - it 'migrates a disabled scope with gradualRolloutUserId and userWithId strategies' do - flag = setup - scope = scopes.create!(feature_flag_id: flag.id, active: false, strategies: [ - { name: 'gradualRolloutUserId', parameters: { groupId: 'default', percentage: '50' } }, - { name: 'userWithId', parameters: { userIds: '5' } } - ]) - - disable_migrations_output { migrate! } - - scope.reload - expect(scope.active).to eq(true) - expect(scope.strategies).to eq([{ 'name' => 'userWithId', 'parameters' => { 'userIds' => '5' } }]) - end - - it 'migrates a disabled scope with default and userWithId strategies' do - flag = setup - scope = scopes.create!(feature_flag_id: flag.id, active: false, strategies: [ - { name: 'default', parameters: {} }, - { name: 'userWithId', parameters: { userIds: 'amy@gmail.com,karen@gmail.com' } } - ]) - - disable_migrations_output { migrate! } - - scope.reload - expect(scope.active).to eq(true) - expect(scope.strategies).to eq([{ 'name' => 'userWithId', 'parameters' => { 'userIds' => 'amy@gmail.com,karen@gmail.com' } }]) - end - - it 'migrates an enabled scope with default and userWithId strategies' do - flag = setup - scope = scopes.create!(feature_flag_id: flag.id, active: true, strategies: [ - { name: 'default', parameters: {} }, - { name: 'userWithId', parameters: { userIds: 'tim' } } - ]) - - disable_migrations_output { migrate! } - - scope.reload - expect(scope.active).to eq(true) - expect(scope.strategies).to eq([{ 'name' => 'default', 'parameters' => {} }]) - end - - it 'does not alter an enabled scope with gradualRolloutUserId and userWithId strategies' do - flag = setup - scope = scopes.create!(feature_flag_id: flag.id, active: true, strategies: [ - { name: 'gradualRolloutUserId', parameters: { groupId: 'default', percentage: '50' } }, - { name: 'userWithId', parameters: { userIds: '5' } } - ]) - - disable_migrations_output { migrate! } - - scope.reload - expect(scope.active).to eq(true) - expect(scope.strategies).to eq([ - { 'name' => 'gradualRolloutUserId', 'parameters' => { 'groupId' => 'default', 'percentage' => '50' } }, - { 'name' => 'userWithId', 'parameters' => { 'userIds' => '5' } } - ]) - end - - it 'does not alter a disabled scope without a userWithId strategy' do - flag = setup - scope = scopes.create!(feature_flag_id: flag.id, active: false, strategies: [ - { name: 'gradualRolloutUserId', parameters: { percentage: '60' } } - ]) - - disable_migrations_output { migrate! } - - scope.reload - expect(scope.active).to eq(false) - expect(scope.strategies).to eq([ - { 'name' => 'gradualRolloutUserId', 'parameters' => { 'percentage' => '60' } } - ]) - end - - it 'does not alter an enabled scope without a userWithId strategy' do - flag = setup - scope = scopes.create!(feature_flag_id: flag.id, active: true, strategies: [ - { name: 'default', parameters: {} } - ]) - - disable_migrations_output { migrate! } - - scope.reload - expect(scope.active).to eq(true) - expect(scope.strategies).to eq([ - { 'name' => 'default', 'parameters' => {} } - ]) - end - - it 'migrates multiple scopes' do - flag = setup - scope_a = scopes.create!(feature_flag_id: flag.id, active: false, strategies: [ - { name: 'gradualRolloutUserId', parameters: { groupId: 'default', percentage: '50' } }, - { name: 'userWithId', parameters: { userIds: '5,6,7' } } - ]) - scope_b = scopes.create!(feature_flag_id: flag.id, active: false, environment_scope: 'production', strategies: [ - { name: 'default', parameters: {} }, - { name: 'userWithId', parameters: { userIds: 'lisa,carol' } } - ]) - - disable_migrations_output { migrate! } - - scope_a.reload - scope_b.reload - expect(scope_a.active).to eq(true) - expect(scope_a.strategies).to eq([{ 'name' => 'userWithId', 'parameters' => { 'userIds' => '5,6,7' } }]) - expect(scope_b.active).to eq(true) - expect(scope_b.strategies).to eq([{ 'name' => 'userWithId', 'parameters' => { 'userIds' => 'lisa,carol' } }]) - end -end diff --git a/spec/migrations/migrate_storage_migrator_sidekiq_queue_spec.rb b/spec/migrations/migrate_storage_migrator_sidekiq_queue_spec.rb deleted file mode 100644 index 4db819f2fa1..00000000000 --- a/spec/migrations/migrate_storage_migrator_sidekiq_queue_spec.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe MigrateStorageMigratorSidekiqQueue, :redis do - include Gitlab::Database::MigrationHelpers - include StubWorker - - context 'when there are jobs in the queues' do - it 'correctly migrates queue when migrating up' do - Sidekiq::Testing.disable! do - stub_worker(queue: :storage_migrator).perform_async(1, 5) - - described_class.new.up - - expect(sidekiq_queue_length('storage_migrator')).to eq 0 - expect(sidekiq_queue_length('hashed_storage:hashed_storage_migrator')).to eq 1 - end - end - - it 'correctly migrates queue when migrating down' do - Sidekiq::Testing.disable! do - stub_worker(queue: :'hashed_storage:hashed_storage_migrator').perform_async(1, 5) - - described_class.new.down - - expect(sidekiq_queue_length('storage_migrator')).to eq 1 - expect(sidekiq_queue_length('hashed_storage:hashed_storage_migrator')).to eq 0 - end - end - end - - context 'when there are no jobs in the queues' do - it 'does not raise error when migrating up' do - expect { described_class.new.up }.not_to raise_error - end - - it 'does not raise error when migrating down' do - expect { described_class.new.down }.not_to raise_error - end - end -end diff --git a/spec/migrations/move_limits_from_plans_spec.rb b/spec/migrations/move_limits_from_plans_spec.rb deleted file mode 100644 index 92ac804733f..00000000000 --- a/spec/migrations/move_limits_from_plans_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe MoveLimitsFromPlans do - let(:plans) { table(:plans) } - let(:plan_limits) { table(:plan_limits) } - - let!(:gold_plan) { plans.create!(name: 'gold', title: 'Gold', active_pipelines_limit: 20, pipeline_size_limit: 21, active_jobs_limit: 22) } - let!(:silver_plan) { plans.create!(name: 'silver', title: 'Silver', active_pipelines_limit: 30, pipeline_size_limit: 31, active_jobs_limit: 32) } - let!(:bronze_plan) { plans.create!(name: 'bronze', title: 'Bronze', active_pipelines_limit: 40, pipeline_size_limit: 41, active_jobs_limit: 42) } - let!(:free_plan) { plans.create!(name: 'free', title: 'Free', active_pipelines_limit: 50, pipeline_size_limit: 51, active_jobs_limit: 52) } - let!(:other_plan) { plans.create!(name: 'other', title: 'Other', active_pipelines_limit: nil, pipeline_size_limit: nil, active_jobs_limit: 0) } - - describe 'migrate' do - it 'populates plan_limits from all the records in plans' do - expect { migrate! }.to change { plan_limits.count }.by 5 - end - - it 'copies plan limits and plan.id into to plan_limits table' do - migrate! - - new_data = plan_limits.pluck(:plan_id, :ci_active_pipelines, :ci_pipeline_size, :ci_active_jobs) - expected_data = [ - [gold_plan.id, 20, 21, 22], - [silver_plan.id, 30, 31, 32], - [bronze_plan.id, 40, 41, 42], - [free_plan.id, 50, 51, 52], - [other_plan.id, 0, 0, 0] - ] - expect(new_data).to contain_exactly(*expected_data) - end - end -end diff --git a/spec/migrations/nullify_users_role_spec.rb b/spec/migrations/nullify_users_role_spec.rb deleted file mode 100644 index 11056d9cf0c..00000000000 --- a/spec/migrations/nullify_users_role_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe NullifyUsersRole do - let(:users) { table(:users) } - - before do - allow(Gitlab).to receive(:com?).and_return(true) - - users.create!(role: 0, updated_at: '2019-11-04 12:08:00', projects_limit: 0, email: '1') - users.create!(role: 1, updated_at: '2019-11-04 12:08:00', projects_limit: 0, email: '2') - users.create!(role: 0, updated_at: '2019-11-06 12:08:00', projects_limit: 0, email: '3') - - migrate! - end - - it 'nullifies the role of the user with updated_at < 2019-11-05 12:08:00 and a role of 0' do - expect(users.where(role: nil).count).to eq(1) - expect(users.find_by(role: nil).email).to eq('1') - end - - it 'leaves the user with role of 1' do - expect(users.where(role: 1).count).to eq(1) - expect(users.find_by(role: 1).email).to eq('2') - end - - it 'leaves the user with updated_at > 2019-11-05 12:08:00' do - expect(users.where(role: 0).count).to eq(1) - expect(users.find_by(role: 0).email).to eq('3') - end -end diff --git a/spec/migrations/populate_project_statistics_packages_size_spec.rb b/spec/migrations/populate_project_statistics_packages_size_spec.rb deleted file mode 100644 index af9237f4bd6..00000000000 --- a/spec/migrations/populate_project_statistics_packages_size_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe PopulateProjectStatisticsPackagesSize do - let(:project_statistics) { table(:project_statistics) } - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:packages) { table(:packages_packages) } - let(:package_files) { table(:packages_package_files) } - - let(:file_size) { 1.kilobyte } - let(:repo_size) { 2.megabytes } - let(:lfs_size) { 3.gigabytes } - let(:artifacts_size) { 4.terabytes } - let(:storage_size) { repo_size + lfs_size + artifacts_size } - - let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') } - let(:package) { packages.create!(project_id: project.id, name: 'a package', package_type: 1) } - let(:project) { projects.create!(namespace_id: namespace.id) } - - let!(:statistics) { project_statistics.create!(project_id: project.id, namespace_id: namespace.id, storage_size: storage_size, repository_size: repo_size, lfs_objects_size: lfs_size, build_artifacts_size: artifacts_size) } - let!(:package_file) { package_files.create!(package_id: package.id, file: 'a file.txt', file_name: 'a file.txt', size: file_size)} - - it 'backfills ProjectStatistics packages_size' do - expect { migrate! } - .to change { statistics.reload.packages_size } - .from(nil).to(file_size) - end - - it 'updates ProjectStatistics storage_size' do - expect { migrate! } - .to change { statistics.reload.storage_size } - .by(file_size) - end -end diff --git a/spec/migrations/populate_rule_type_on_approval_merge_request_rules_spec.rb b/spec/migrations/populate_rule_type_on_approval_merge_request_rules_spec.rb deleted file mode 100644 index 7dc3f5a1004..00000000000 --- a/spec/migrations/populate_rule_type_on_approval_merge_request_rules_spec.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe PopulateRuleTypeOnApprovalMergeRequestRules do - let(:migration) { described_class.new } - - describe '#up' do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:merge_requests) { table(:merge_requests) } - let(:approval_rules) { table(:approval_merge_request_rules) } - - # We use integers here since at the time of writing CE does not yet have the - # appropriate models and enum definitions. - let(:regular_rule_type) { 1 } - let(:code_owner_rule_type) { 2 } - - before do - namespaces.create!(id: 11, name: 'gitlab', path: 'gitlab') - projects.create!(id: 101, namespace_id: 11, name: 'gitlab', path: 'gitlab') - merge_requests.create!(id: 1, target_project_id: 101, source_project_id: 101, target_branch: 'feature', source_branch: 'master') - - approval_rules.create!(id: 1, merge_request_id: 1, name: "Default", code_owner: false, rule_type: regular_rule_type) - approval_rules.create!(id: 2, merge_request_id: 1, name: "Code Owners", code_owner: true, rule_type: regular_rule_type) - end - - it 'backfills ApprovalMergeRequestRules code_owner rule_type' do - expect(approval_rules.where(rule_type: regular_rule_type).pluck(:id)).to contain_exactly(1, 2) - expect(approval_rules.where(rule_type: code_owner_rule_type).pluck(:id)).to be_empty - - migrate! - - expect(approval_rules.where(rule_type: regular_rule_type).pluck(:id)).to contain_exactly(1) - expect(approval_rules.where(rule_type: code_owner_rule_type).pluck(:id)).to contain_exactly(2) - end - end -end diff --git a/spec/migrations/recreate_index_security_ci_builds_on_name_and_id_parser_features_spec.rb b/spec/migrations/recreate_index_security_ci_builds_on_name_and_id_parser_features_spec.rb new file mode 100644 index 00000000000..77824a743fb --- /dev/null +++ b/spec/migrations/recreate_index_security_ci_builds_on_name_and_id_parser_features_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_migration! + +RSpec.describe RecreateIndexSecurityCiBuildsOnNameAndIdParserFeatures, :migration do + let(:db) { described_class.new } + let(:pg_class) { table(:pg_class) } + let(:pg_index) { table(:pg_index) } + let(:async_indexes) { table(:postgres_async_indexes) } + + it "recreates index" do + reversible_migration do |migration| + migration.before -> { + expect(async_indexes.where(name: described_class::OLD_INDEX_NAME).exists?).to be false + expect(db.index_exists?(described_class::TABLE, described_class::COLUMNS, name: described_class::OLD_INDEX_NAME)).to be true + expect(db.index_exists?(described_class::TABLE, described_class::COLUMNS, name: described_class::NEW_INDEX_NAME)).to be false + } + + migration.after -> { + expect(async_indexes.where(name: described_class::OLD_INDEX_NAME).exists?).to be true + expect(db.index_exists?(described_class::TABLE, described_class::COLUMNS, name: described_class::OLD_INDEX_NAME)).to be false + expect(db.index_exists?(described_class::TABLE, described_class::COLUMNS, name: described_class::NEW_INDEX_NAME)).to be true + } + end + end +end diff --git a/spec/migrations/remove_empty_github_service_templates_spec.rb b/spec/migrations/remove_empty_github_service_templates_spec.rb deleted file mode 100644 index ad84187c298..00000000000 --- a/spec/migrations/remove_empty_github_service_templates_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe RemoveEmptyGithubServiceTemplates do - subject(:migration) { described_class.new } - - let(:services) do - table(:services).tap do |klass| - klass.class_eval do - serialize :properties, JSON - end - end - end - - before do - services.delete_all - - create_service(properties: nil) - create_service(properties: {}) - create_service(properties: { some: :value }) - create_service(properties: {}, template: false) - create_service(properties: {}, type: 'SomeType') - end - - def all_service_properties - services.where(template: true, type: 'GithubService').pluck(:properties) - end - - it 'correctly migrates up and down service templates' do - reversible_migration do |migration| - migration.before -> do - expect(services.count).to eq(5) - - expect(all_service_properties) - .to match(a_collection_containing_exactly(nil, {}, { 'some' => 'value' })) - end - - migration.after -> do - expect(services.count).to eq(4) - - expect(all_service_properties) - .to match(a_collection_containing_exactly(nil, { 'some' => 'value' })) - end - end - end - - def create_service(params) - data = { template: true, type: 'GithubService' } - data.merge!(params) - - services.create!(data) - end -end diff --git a/spec/migrations/remove_schedule_and_status_from_pending_alert_escalations_spec.rb b/spec/migrations/remove_schedule_and_status_from_pending_alert_escalations_spec.rb new file mode 100644 index 00000000000..f595261ff90 --- /dev/null +++ b/spec/migrations/remove_schedule_and_status_from_pending_alert_escalations_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe RemoveScheduleAndStatusFromPendingAlertEscalations do + let(:escalations) { table(:incident_management_pending_alert_escalations) } + let(:schedule_index) { 'index_incident_management_pending_alert_escalations_on_schedule' } + let(:schedule_foreign_key) { 'fk_rails_fcbfd9338b' } + + it 'correctly migrates up and down' do + reversible_migration do |migration| + migration.before -> { + expect(escalations.column_names).to include('schedule_id', 'status') + expect(escalations_indexes).to include(schedule_index) + expect(escalations_constraints).to include(schedule_foreign_key) + } + + migration.after -> { + escalations.reset_column_information + expect(escalations.column_names).not_to include('schedule_id', 'status') + expect(escalations_indexes).not_to include(schedule_index) + expect(escalations_constraints).not_to include(schedule_foreign_key) + } + end + end + + private + + def escalations_indexes + ActiveRecord::Base.connection.indexes(:incident_management_pending_alert_escalations).collect(&:name) + end + + def escalations_constraints + ActiveRecord::Base.connection.foreign_keys(:incident_management_pending_alert_escalations).collect(&:name) + end +end diff --git a/spec/migrations/schedule_fill_valid_time_for_pages_domain_certificates_spec.rb b/spec/migrations/schedule_fill_valid_time_for_pages_domain_certificates_spec.rb deleted file mode 100644 index 45bd5073d55..00000000000 --- a/spec/migrations/schedule_fill_valid_time_for_pages_domain_certificates_spec.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ScheduleFillValidTimeForPagesDomainCertificates do - let(:migration_class) { described_class::MIGRATION } - let(:migration_name) { migration_class.to_s.demodulize } - - let(:domains_table) { table(:pages_domains) } - - let(:certificate) do - File.read('spec/fixtures/passphrase_x509_certificate.crt') - end - - before do - domains_table.create!(domain: "domain1.example.com", verification_code: "123") - domains_table.create!(domain: "domain2.example.com", verification_code: "123", certificate: '') - domains_table.create!(domain: "domain3.example.com", verification_code: "123", certificate: certificate) - domains_table.create!(domain: "domain4.example.com", verification_code: "123", certificate: certificate) - end - - it 'correctly schedules background migrations' do - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - first_id = domains_table.find_by_domain("domain3.example.com").id - last_id = domains_table.find_by_domain("domain4.example.com").id - - expect(migration_name).to be_scheduled_delayed_migration(5.minutes, first_id, last_id) - expect(BackgroundMigrationWorker.jobs.size).to eq(1) - end - end - end - - it 'sets certificate valid_not_before/not_after', :sidekiq_might_not_need_inline do - perform_enqueued_jobs do - migrate! - - domain = domains_table.find_by_domain("domain3.example.com") - expect(domain.certificate_valid_not_before) - .to eq(Time.parse("2018-03-23 14:02:08 UTC")) - expect(domain.certificate_valid_not_after) - .to eq(Time.parse("2019-03-23 14:02:08 UTC")) - end - end -end diff --git a/spec/migrations/schedule_pages_metadata_migration_spec.rb b/spec/migrations/schedule_pages_metadata_migration_spec.rb deleted file mode 100644 index 96fbc1f9f51..00000000000 --- a/spec/migrations/schedule_pages_metadata_migration_spec.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe SchedulePagesMetadataMigration do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - - before do - stub_const("#{described_class.name}::BATCH_SIZE", 1) - - namespaces.create!(id: 11, name: 'gitlab', path: 'gitlab-org') - projects.create!(id: 111, namespace_id: 11, name: 'Project 111') - projects.create!(id: 114, namespace_id: 11, name: 'Project 114') - end - - it 'schedules pages metadata migration' do - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, 111, 111) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, 114, 114) - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end -end diff --git a/spec/migrations/schedule_populate_merge_request_assignees_table_spec.rb b/spec/migrations/schedule_populate_merge_request_assignees_table_spec.rb deleted file mode 100644 index 3caab64a72d..00000000000 --- a/spec/migrations/schedule_populate_merge_request_assignees_table_spec.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe SchedulePopulateMergeRequestAssigneesTable do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') } - let(:project) { projects.create!(namespace_id: namespace.id, name: 'foo') } - let(:merge_requests) { table(:merge_requests) } - - def create_merge_request(id) - params = { - id: id, - target_project_id: project.id, - target_branch: 'master', - source_project_id: project.id, - source_branch: 'mr name', - title: "mr name#{id}" - } - - merge_requests.create!(params) - end - - it 'correctly schedules background migrations' do - create_merge_request(1) - create_merge_request(2) - create_merge_request(3) - - stub_const("#{described_class.name}::BATCH_SIZE", 2) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(8.minutes, 1, 2) - - expect(described_class::MIGRATION) - .to be_scheduled_delayed_migration(16.minutes, 3, 3) - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end -end diff --git a/spec/migrations/schedule_populate_status_column_of_security_scans_spec.rb b/spec/migrations/schedule_populate_status_column_of_security_scans_spec.rb new file mode 100644 index 00000000000..601935db8db --- /dev/null +++ b/spec/migrations/schedule_populate_status_column_of_security_scans_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe SchedulePopulateStatusColumnOfSecurityScans do + before do + allow(Gitlab).to receive(:ee?).and_return(ee?) + stub_const("#{described_class.name}::BATCH_SIZE", 1) + end + + context 'when the Gitlab instance is CE' do + let(:ee?) { false } + + it 'does not run the migration' do + expect { migrate! }.not_to change { BackgroundMigrationWorker.jobs.size } + end + end + + context 'when the Gitlab instance is EE' do + let(:ee?) { true } + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:pipelines) { table(:ci_pipelines) } + let(:builds) { table(:ci_builds) } + let(:security_scans) { table(:security_scans) } + + let(:namespace) { namespaces.create!(name: "foo", path: "bar") } + let(:project) { projects.create!(namespace_id: namespace.id) } + let(:pipeline) { pipelines.create!(project_id: project.id, ref: 'master', sha: 'adf43c3a', status: 'success') } + let(:ci_build) { builds.create!(commit_id: pipeline.id, retried: false, type: 'Ci::Build') } + + let!(:security_scan_1) { security_scans.create!(build_id: ci_build.id, scan_type: 1) } + let!(:security_scan_2) { security_scans.create!(build_id: ci_build.id, scan_type: 2) } + + around do |example| + freeze_time { Sidekiq::Testing.fake! { example.run } } + end + + it 'schedules the background jobs', :aggregate_failures do + migrate! + + expect(BackgroundMigrationWorker.jobs.size).to be(2) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, security_scan_1.id, security_scan_1.id) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, security_scan_2.id, security_scan_2.id) + end + end +end diff --git a/spec/migrations/schedule_sync_issuables_state_id_spec.rb b/spec/migrations/schedule_sync_issuables_state_id_spec.rb deleted file mode 100644 index 5a7105a0c84..00000000000 --- a/spec/migrations/schedule_sync_issuables_state_id_spec.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ScheduleSyncIssuablesStateId do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:merge_requests) { table(:merge_requests) } - let(:issues) { table(:issues) } - let(:migration) { described_class.new } - let(:group) { namespaces.create!(name: 'gitlab', path: 'gitlab') } - let(:project) { projects.create!(namespace_id: group.id) } - - shared_examples 'scheduling migrations' do - before do - Sidekiq::Worker.clear_all - stub_const("#{described_class.name}::BATCH_SIZE", 2) - end - - it 'correctly schedules issuable sync background migration' do - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(migration).to be_scheduled_delayed_migration(120.seconds, resource_1.id, resource_2.id) - expect(migration).to be_scheduled_delayed_migration(240.seconds, resource_3.id, resource_4.id) - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end - end - - describe '#up' do - context 'issues' do - it 'migrates state column to integer', :sidekiq_might_not_need_inline do - opened_issue = issues.create!(description: 'first', state: 'opened') - closed_issue = issues.create!(description: 'second', state: 'closed') - invalid_state_issue = issues.create!(description: 'fourth', state: 'not valid') - - migrate! - - expect(opened_issue.reload.state_id).to eq(Issue.available_states[:opened]) - expect(closed_issue.reload.state_id).to eq(Issue.available_states[:closed]) - expect(invalid_state_issue.reload.state_id).to be_nil - end - - it_behaves_like 'scheduling migrations' do - let(:migration) { described_class::ISSUES_MIGRATION } - let!(:resource_1) { issues.create!(description: 'first', state: 'opened') } - let!(:resource_2) { issues.create!(description: 'second', state: 'closed') } - let!(:resource_3) { issues.create!(description: 'third', state: 'closed') } - let!(:resource_4) { issues.create!(description: 'fourth', state: 'closed') } - end - end - - context 'merge requests' do - it 'migrates state column to integer', :sidekiq_might_not_need_inline do - opened_merge_request = merge_requests.create!(state: 'opened', target_project_id: project.id, target_branch: 'feature1', source_branch: 'master') - closed_merge_request = merge_requests.create!(state: 'closed', target_project_id: project.id, target_branch: 'feature2', source_branch: 'master') - merged_merge_request = merge_requests.create!(state: 'merged', target_project_id: project.id, target_branch: 'feature3', source_branch: 'master') - locked_merge_request = merge_requests.create!(state: 'locked', target_project_id: project.id, target_branch: 'feature4', source_branch: 'master') - - migrate! - - expect(opened_merge_request.reload.state_id).to eq(MergeRequest.available_states[:opened]) - expect(closed_merge_request.reload.state_id).to eq(MergeRequest.available_states[:closed]) - expect(merged_merge_request.reload.state_id).to eq(MergeRequest.available_states[:merged]) - expect(locked_merge_request.reload.state_id).to eq(MergeRequest.available_states[:locked]) - end - - it_behaves_like 'scheduling migrations' do - let(:migration) { described_class::MERGE_REQUESTS_MIGRATION } - let!(:resource_1) { merge_requests.create!(state: 'opened', target_project_id: project.id, target_branch: 'feature1', source_branch: 'master') } - let!(:resource_2) { merge_requests.create!(state: 'closed', target_project_id: project.id, target_branch: 'feature2', source_branch: 'master') } - let!(:resource_3) { merge_requests.create!(state: 'merged', target_project_id: project.id, target_branch: 'feature3', source_branch: 'master') } - let!(:resource_4) { merge_requests.create!(state: 'locked', target_project_id: project.id, target_branch: 'feature4', source_branch: 'master') } - end - end - end -end diff --git a/spec/migrations/schedule_sync_issuables_state_id_where_nil_spec.rb b/spec/migrations/schedule_sync_issuables_state_id_where_nil_spec.rb deleted file mode 100644 index d8eaaa1df04..00000000000 --- a/spec/migrations/schedule_sync_issuables_state_id_where_nil_spec.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ScheduleSyncIssuablesStateIdWhereNil do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:merge_requests) { table(:merge_requests) } - let(:issues) { table(:issues) } - let(:migration) { described_class.new } - let(:group) { namespaces.create!(name: 'gitlab', path: 'gitlab') } - let(:project) { projects.create!(namespace_id: group.id) } - - shared_examples 'scheduling migrations' do - before do - Sidekiq::Worker.clear_all - stub_const("#{described_class.name}::BATCH_SIZE", 2) - end - - it 'correctly schedules issuable sync background migration' do - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(migration).to be_scheduled_delayed_migration(120.seconds, resource_1.id, resource_3.id) - expect(migration).to be_scheduled_delayed_migration(240.seconds, resource_5.id, resource_5.id) - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - end - end - end - end - - describe '#up' do - context 'issues' do - it_behaves_like 'scheduling migrations' do - let(:migration) { described_class::ISSUES_MIGRATION } - let!(:resource_1) { issues.create!(description: 'first', state: 'opened', state_id: nil) } - let!(:resource_2) { issues.create!(description: 'second', state: 'closed', state_id: 2) } - let!(:resource_3) { issues.create!(description: 'third', state: 'closed', state_id: nil) } - let!(:resource_4) { issues.create!(description: 'fourth', state: 'closed', state_id: 2) } - let!(:resource_5) { issues.create!(description: 'fifth', state: 'closed', state_id: nil) } - end - end - - context 'merge requests' do - it_behaves_like 'scheduling migrations' do - let(:migration) { described_class::MERGE_REQUESTS_MIGRATION } - let!(:resource_1) { merge_requests.create!(state: 'opened', state_id: nil, target_project_id: project.id, target_branch: 'feature1', source_branch: 'master') } - let!(:resource_2) { merge_requests.create!(state: 'closed', state_id: 2, target_project_id: project.id, target_branch: 'feature2', source_branch: 'master') } - let!(:resource_3) { merge_requests.create!(state: 'merged', state_id: nil, target_project_id: project.id, target_branch: 'feature3', source_branch: 'master') } - let!(:resource_4) { merge_requests.create!(state: 'locked', state_id: 3, target_project_id: project.id, target_branch: 'feature4', source_branch: 'master') } - let!(:resource_5) { merge_requests.create!(state: 'locked', state_id: nil, target_project_id: project.id, target_branch: 'feature4', source_branch: 'master') } - end - end - end -end diff --git a/spec/migrations/set_issue_id_for_all_versions_spec.rb b/spec/migrations/set_issue_id_for_all_versions_spec.rb deleted file mode 100644 index 78bc4bbce1c..00000000000 --- a/spec/migrations/set_issue_id_for_all_versions_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe SetIssueIdForAllVersions do - let(:projects) { table(:projects) } - let(:issues) { table(:issues) } - let(:designs) { table(:design_management_designs) } - let(:designs_versions) { table(:design_management_designs_versions) } - let(:versions) { table(:design_management_versions) } - - before do - @project = projects.create!(name: 'gitlab', path: 'gitlab-org/gitlab-ce', namespace_id: 1) - - @issue_1 = issues.create!(description: 'first', project_id: @project.id) - @issue_2 = issues.create!(description: 'second', project_id: @project.id) - - @design_1 = designs.create!(issue_id: @issue_1.id, filename: 'homepage-1.jpg', project_id: @project.id) - @design_2 = designs.create!(issue_id: @issue_2.id, filename: 'homepage-2.jpg', project_id: @project.id) - - @version_1 = versions.create!(sha: 'foo') - @version_2 = versions.create!(sha: 'bar') - - designs_versions.create!(version_id: @version_1.id, design_id: @design_1.id) - designs_versions.create!(version_id: @version_2.id, design_id: @design_2.id) - end - - it 'correctly sets issue_id' do - expect(versions.where(issue_id: nil).count).to eq(2) - - migrate! - - expect(versions.where(issue_id: nil).count).to eq(0) - expect(versions.find(@version_1.id).issue_id).to eq(@issue_1.id) - expect(versions.find(@version_2.id).issue_id).to eq(@issue_2.id) - end -end diff --git a/spec/migrations/sync_issuables_state_id_spec.rb b/spec/migrations/sync_issuables_state_id_spec.rb deleted file mode 100644 index 67403893f74..00000000000 --- a/spec/migrations/sync_issuables_state_id_spec.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe SyncIssuablesStateId do - let(:migration) { described_class.new } - - describe '#up' do - let(:issues) { table(:issues) } - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:merge_requests) { table(:merge_requests) } - let(:group) { namespaces.create!(name: 'gitlab', path: 'gitlab') } - let(:project) { projects.create!(namespace_id: group.id) } - # These state_ids should be the same defined on Issue/MergeRequest models - let(:state_ids) { { opened: 1, closed: 2, merged: 3, locked: 4 } } - - it 'migrates state column to state_id as integer' do - opened_issue = issues.create!(description: 'first', state: 'opened') - closed_issue = issues.create!(description: 'second', state: 'closed') - unknown_state_issue = issues.create!(description: 'second', state: 'unknown') - opened_merge_request = merge_requests.create!(state: 'opened', target_project_id: project.id, target_branch: 'feature1', source_branch: 'master') - closed_merge_request = merge_requests.create!(state: 'closed', target_project_id: project.id, target_branch: 'feature2', source_branch: 'master') - merged_merge_request = merge_requests.create!(state: 'merged', target_project_id: project.id, target_branch: 'feature3', source_branch: 'master') - locked_merge_request = merge_requests.create!(state: 'locked', target_project_id: project.id, target_branch: 'feature4', source_branch: 'master') - unknown_state_merge_request = merge_requests.create!(state: 'unknown', target_project_id: project.id, target_branch: 'feature4', source_branch: 'master') - - migrate! - - expect(opened_issue.reload.state_id).to eq(state_ids[:opened]) - expect(closed_issue.reload.state_id).to eq(state_ids[:closed]) - expect(unknown_state_issue.reload.state_id).to eq(state_ids[:closed]) - expect(opened_merge_request.reload.state_id).to eq(state_ids[:opened]) - expect(closed_merge_request.reload.state_id).to eq(state_ids[:closed]) - expect(merged_merge_request.reload.state_id).to eq(state_ids[:merged]) - expect(locked_merge_request.reload.state_id).to eq(state_ids[:locked]) - expect(unknown_state_merge_request.reload.state_id).to eq(state_ids[:closed]) - end - end -end diff --git a/spec/migrations/truncate_user_fullname_spec.rb b/spec/migrations/truncate_user_fullname_spec.rb deleted file mode 100644 index dc5bef06cdc..00000000000 --- a/spec/migrations/truncate_user_fullname_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe TruncateUserFullname do - let(:users) { table(:users) } - - let(:user_short) { create_user(name: 'abc', email: 'test_short@example.com') } - let(:user_long) { create_user(name: 'a' * 200 + 'z', email: 'test_long@example.com') } - - def create_user(params) - users.create!(params.merge(projects_limit: 0)) - end - - it 'truncates user full name to the first 128 characters' do - expect { migrate! }.to change { user_long.reload.name }.to('a' * 128) - end - - it 'does not truncate short names' do - expect { migrate! }.not_to change { user_short.reload.name.length } - end -end diff --git a/spec/migrations/update_minimum_password_length_spec.rb b/spec/migrations/update_minimum_password_length_spec.rb deleted file mode 100644 index e40d090fd77..00000000000 --- a/spec/migrations/update_minimum_password_length_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe UpdateMinimumPasswordLength do - let(:application_settings) { table(:application_settings) } - let(:application_setting) do - application_settings.create!( - minimum_password_length: ApplicationSetting::DEFAULT_MINIMUM_PASSWORD_LENGTH - ) - end - - before do - stub_const('ApplicationSetting::DEFAULT_MINIMUM_PASSWORD_LENGTH', 10) - allow(Devise).to receive(:password_length).and_return(12..20) - end - - it 'correctly migrates minimum_password_length' do - reversible_migration do |migration| - migration.before -> { - expect(application_setting.reload.minimum_password_length).to eq(10) - } - - migration.after -> { - expect(application_setting.reload.minimum_password_length).to eq(12) - } - end - end -end diff --git a/spec/models/analytics/cycle_analytics/issue_stage_event_spec.rb b/spec/models/analytics/cycle_analytics/issue_stage_event_spec.rb index 3e6d4ebd0a2..c0d5b9203b8 100644 --- a/spec/models/analytics/cycle_analytics/issue_stage_event_spec.rb +++ b/spec/models/analytics/cycle_analytics/issue_stage_event_spec.rb @@ -8,4 +8,6 @@ RSpec.describe Analytics::CycleAnalytics::IssueStageEvent do it { is_expected.to validate_presence_of(:group_id) } it { is_expected.to validate_presence_of(:project_id) } it { is_expected.to validate_presence_of(:start_event_timestamp) } + + it_behaves_like 'StageEventModel' end diff --git a/spec/models/analytics/cycle_analytics/merge_request_stage_event_spec.rb b/spec/models/analytics/cycle_analytics/merge_request_stage_event_spec.rb index 244c5c70286..82a7e66d62a 100644 --- a/spec/models/analytics/cycle_analytics/merge_request_stage_event_spec.rb +++ b/spec/models/analytics/cycle_analytics/merge_request_stage_event_spec.rb @@ -8,4 +8,6 @@ RSpec.describe Analytics::CycleAnalytics::MergeRequestStageEvent do it { is_expected.to validate_presence_of(:group_id) } it { is_expected.to validate_presence_of(:project_id) } it { is_expected.to validate_presence_of(:start_event_timestamp) } + + it_behaves_like 'StageEventModel' end diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb index efb92ddaea0..f0212da3041 100644 --- a/spec/models/application_record_spec.rb +++ b/spec/models/application_record_spec.rb @@ -194,7 +194,7 @@ RSpec.describe ApplicationRecord do end context 'with database load balancing' do - let(:session) { double(:session) } + let(:session) { Gitlab::Database::LoadBalancing::Session.new } before do allow(::Gitlab::Database::LoadBalancing::Session).to receive(:current).and_return(session) diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 3e264867703..8ad83da61f3 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -77,6 +77,9 @@ RSpec.describe ApplicationSetting do it { is_expected.to validate_numericality_of(:container_registry_cleanup_tags_service_max_list_size).only_integer.is_greater_than_or_equal_to(0) } it { is_expected.to validate_numericality_of(:container_registry_expiration_policies_worker_capacity).only_integer.is_greater_than_or_equal_to(0) } + it { is_expected.to validate_numericality_of(:dependency_proxy_ttl_group_policy_worker_capacity).only_integer.is_greater_than_or_equal_to(0) } + it { is_expected.not_to allow_value(nil).for(:dependency_proxy_ttl_group_policy_worker_capacity) } + it { is_expected.to validate_numericality_of(:snippet_size_limit).only_integer.is_greater_than(0) } it { is_expected.to validate_numericality_of(:wiki_page_max_content_bytes).only_integer.is_greater_than_or_equal_to(1024) } it { is_expected.to validate_presence_of(:max_artifacts_size) } @@ -946,6 +949,10 @@ RSpec.describe ApplicationSetting do throttle_unauthenticated_files_api_period_in_seconds throttle_authenticated_files_api_requests_per_period throttle_authenticated_files_api_period_in_seconds + throttle_unauthenticated_deprecated_api_requests_per_period + throttle_unauthenticated_deprecated_api_period_in_seconds + throttle_authenticated_deprecated_api_requests_per_period + throttle_authenticated_deprecated_api_period_in_seconds throttle_authenticated_git_lfs_requests_per_period throttle_authenticated_git_lfs_period_in_seconds ] diff --git a/spec/models/bulk_import_spec.rb b/spec/models/bulk_import_spec.rb index 4cfec6b20b7..ea002a7b174 100644 --- a/spec/models/bulk_import_spec.rb +++ b/spec/models/bulk_import_spec.rb @@ -21,4 +21,18 @@ RSpec.describe BulkImport, type: :model do expect(described_class.all_human_statuses).to contain_exactly('created', 'started', 'finished', 'failed') end end + + describe '.min_gl_version_for_project' do + it { expect(described_class.min_gl_version_for_project_migration).to be_a(Gitlab::VersionInfo) } + it { expect(described_class.min_gl_version_for_project_migration.to_s).to eq('14.4.0') } + end + + describe '#source_version_info' do + it 'returns source_version as Gitlab::VersionInfo' do + bulk_import = build(:bulk_import, source_version: '9.13.2') + + expect(bulk_import.source_version_info).to be_a(Gitlab::VersionInfo) + expect(bulk_import.source_version_info.to_s).to eq(bulk_import.source_version) + end + end end diff --git a/spec/models/bulk_imports/entity_spec.rb b/spec/models/bulk_imports/entity_spec.rb index c1cbe61885f..278d7f4bc56 100644 --- a/spec/models/bulk_imports/entity_spec.rb +++ b/spec/models/bulk_imports/entity_spec.rb @@ -179,7 +179,7 @@ RSpec.describe BulkImports::Entity, type: :model do entity = create(:bulk_import_entity, :group_entity) entity.create_pipeline_trackers! - expect(entity.trackers.count).to eq(BulkImports::Groups::Stage.pipelines.count) + expect(entity.trackers.count).to eq(BulkImports::Groups::Stage.new(entity.bulk_import).pipelines.count) expect(entity.trackers.map(&:pipeline_name)).to include(BulkImports::Groups::Pipelines::GroupPipeline.to_s) end end @@ -189,7 +189,7 @@ RSpec.describe BulkImports::Entity, type: :model do entity = create(:bulk_import_entity, :project_entity) entity.create_pipeline_trackers! - expect(entity.trackers.count).to eq(BulkImports::Projects::Stage.pipelines.count) + expect(entity.trackers.count).to eq(BulkImports::Projects::Stage.new(entity.bulk_import).pipelines.count) expect(entity.trackers.map(&:pipeline_name)).to include(BulkImports::Projects::Pipelines::ProjectPipeline.to_s) end end @@ -207,4 +207,40 @@ RSpec.describe BulkImports::Entity, type: :model do expect(entity.pipeline_exists?('BulkImports::Groups::Pipelines::InexistentPipeline')).to eq(false) end end + + describe '#pluralized_name' do + context 'when entity is group' do + it 'returns groups' do + entity = build(:bulk_import_entity, :group_entity) + + expect(entity.pluralized_name).to eq('groups') + end + end + + context 'when entity is project' do + it 'returns projects' do + entity = build(:bulk_import_entity, :project_entity) + + expect(entity.pluralized_name).to eq('projects') + end + end + end + + describe '#export_relations_url_path' do + context 'when entity is group' do + it 'returns group export relations url' do + entity = build(:bulk_import_entity, :group_entity) + + expect(entity.export_relations_url_path).to eq("/groups/#{entity.encoded_source_full_path}/export_relations") + end + end + + context 'when entity is project' do + it 'returns project export relations url' do + entity = build(:bulk_import_entity, :project_entity) + + expect(entity.export_relations_url_path).to eq("/projects/#{entity.encoded_source_full_path}/export_relations") + end + end + end end diff --git a/spec/models/bulk_imports/file_transfer/group_config_spec.rb b/spec/models/bulk_imports/file_transfer/group_config_spec.rb index 1e566a7b042..8660114b719 100644 --- a/spec/models/bulk_imports/file_transfer/group_config_spec.rb +++ b/spec/models/bulk_imports/file_transfer/group_config_spec.rb @@ -23,10 +23,8 @@ RSpec.describe BulkImports::FileTransfer::GroupConfig do end describe '#export_path' do - it 'returns correct export path' do - expect(::Gitlab::ImportExport).to receive(:storage_path).and_return('storage_path') - - expect(subject.export_path).to eq("storage_path/#{exportable.full_path}/#{hex}") + it 'returns tmpdir location' do + expect(subject.export_path).to include(File.join(Dir.tmpdir, 'bulk_imports')) end end diff --git a/spec/models/bulk_imports/file_transfer/project_config_spec.rb b/spec/models/bulk_imports/file_transfer/project_config_spec.rb index db037528ec1..3bd79333f0c 100644 --- a/spec/models/bulk_imports/file_transfer/project_config_spec.rb +++ b/spec/models/bulk_imports/file_transfer/project_config_spec.rb @@ -23,10 +23,8 @@ RSpec.describe BulkImports::FileTransfer::ProjectConfig do end describe '#export_path' do - it 'returns correct export path' do - expect(::Gitlab::ImportExport).to receive(:storage_path).and_return('storage_path') - - expect(subject.export_path).to eq("storage_path/#{exportable.disk_path}/#{hex}") + it 'returns tmpdir location' do + expect(subject.export_path).to include(File.join(Dir.tmpdir, 'bulk_imports')) end end @@ -51,4 +49,46 @@ RSpec.describe BulkImports::FileTransfer::ProjectConfig do expect(subject.relation_excluded_keys('project')).to include('creator_id') end end + + describe '#tree_relation?' do + context 'when it is a tree relation' do + it 'returns true' do + expect(subject.tree_relation?('labels')).to eq(true) + end + end + + context 'when it is not a tree relation' do + it 'returns false' do + expect(subject.tree_relation?('example')).to eq(false) + end + end + end + + describe '#file_relation?' do + context 'when it is a file relation' do + it 'returns true' do + expect(subject.file_relation?('uploads')).to eq(true) + end + end + + context 'when it is not a file relation' do + it 'returns false' do + expect(subject.file_relation?('example')).to eq(false) + end + end + end + + describe '#tree_relation_definition_for' do + it 'returns relation definition' do + expected = { service_desk_setting: { except: [:outgoing_name, :file_template_project_id], include: [] } } + + expect(subject.tree_relation_definition_for('service_desk_setting')).to eq(expected) + end + + context 'when relation is not tree relation' do + it 'returns' do + expect(subject.tree_relation_definition_for('example')).to be_nil + end + end + end end diff --git a/spec/models/bulk_imports/tracker_spec.rb b/spec/models/bulk_imports/tracker_spec.rb index 7f0a7d4f1ae..a72b628e329 100644 --- a/spec/models/bulk_imports/tracker_spec.rb +++ b/spec/models/bulk_imports/tracker_spec.rb @@ -66,7 +66,8 @@ RSpec.describe BulkImports::Tracker, type: :model do describe '#pipeline_class' do it 'returns the pipeline class' do - pipeline_class = BulkImports::Groups::Stage.pipelines.first[1] + bulk_import = create(:bulk_import) + pipeline_class = BulkImports::Groups::Stage.new(bulk_import).pipelines.first[1] tracker = create(:bulk_import_tracker, pipeline_name: pipeline_class) expect(tracker.pipeline_class).to eq(pipeline_class) diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb index 6dd3c40f228..8f1ae9c5f02 100644 --- a/spec/models/ci/bridge_spec.rb +++ b/spec/models/ci/bridge_spec.rb @@ -17,6 +17,8 @@ RSpec.describe Ci::Bridge do { trigger: { project: 'my/project', branch: 'master' } } end + it { is_expected.to respond_to(:runner_features) } + it 'has many sourced pipelines' do expect(bridge).to have_many(:sourced_pipelines) end @@ -76,7 +78,7 @@ RSpec.describe Ci::Bridge do bridge.enqueue! - expect(::Ci::CreateCrossProjectPipelineWorker.jobs.last['args']).to eq([bridge.id]) + expect(::Ci::CreateDownstreamPipelineWorker.jobs.last['args']).to eq([bridge.id]) end end @@ -85,7 +87,7 @@ RSpec.describe Ci::Bridge do bridge.enqueue_waiting_for_resource! - expect(::Ci::CreateCrossProjectPipelineWorker.jobs.last['args']).to eq([bridge.id]) + expect(::Ci::CreateDownstreamPipelineWorker.jobs.last['args']).to match_array([bridge.id]) end it 'raises error when the status is failed' do diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 1e06d566c80..2ebf75a1d8a 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -29,11 +29,13 @@ RSpec.describe Ci::Build do it { is_expected.to have_one(:deployment) } it { is_expected.to have_one(:runner_session) } it { is_expected.to have_one(:trace_metadata) } + it { is_expected.to have_many(:terraform_state_versions).dependent(:nullify).inverse_of(:build) } it { is_expected.to validate_presence_of(:ref) } it { is_expected.to respond_to(:has_trace?) } it { is_expected.to respond_to(:trace) } + it { is_expected.to respond_to(:runner_features) } it { is_expected.to delegate_method(:merge_request?).to(:pipeline) } it { is_expected.to delegate_method(:merge_request_ref?).to(:pipeline) } @@ -345,10 +347,10 @@ RSpec.describe Ci::Build do end describe '#stick_build_if_status_changed' do - it 'sticks the build if the status changed', :db_load_balancing do + it 'sticks the build if the status changed' do job = create(:ci_build, :pending) - expect(Gitlab::Database::LoadBalancing::Sticking).to receive(:stick) + expect(ApplicationRecord.sticking).to receive(:stick) .with(:build, job.id) job.update!(status: :running) @@ -1288,7 +1290,7 @@ RSpec.describe Ci::Build do end end - describe 'state transition as a deployable' do + shared_examples_for 'state transition as a deployable' do subject { build.send(event) } let!(:build) { create(:ci_build, :with_deployment, :start_review_app, project: project, pipeline: pipeline) } @@ -1397,6 +1399,36 @@ RSpec.describe Ci::Build do end end + it_behaves_like 'state transition as a deployable' do + context 'when transits to running' do + let(:event) { :run! } + + context 'when deployment is already running state' do + before do + build.deployment.success! + end + + it 'does not change deployment status and tracks an error' do + expect(Gitlab::ErrorTracking) + .to receive(:track_exception).with( + instance_of(Deployment::StatusSyncError), deployment_id: deployment.id, build_id: build.id) + + with_cross_database_modification_prevented do + expect { subject }.not_to change { deployment.reload.status } + end + end + end + end + end + + context 'when update_deployment_after_transaction_commit feature flag is disabled' do + before do + stub_feature_flags(update_deployment_after_transaction_commit: false) + end + + it_behaves_like 'state transition as a deployable' + end + describe '#on_stop' do subject { build.on_stop } @@ -3946,7 +3978,7 @@ RSpec.describe Ci::Build do end it 'can drop the build' do - expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) + expect(Gitlab::ErrorTracking).to receive(:track_exception) expect { build.drop! }.not_to raise_error @@ -5288,4 +5320,10 @@ RSpec.describe Ci::Build do expect(build.reload.queuing_entry).not_to be_present end end + + it 'does not generate cross DB queries when a record is created via FactoryBot' do + with_cross_database_modification_prevented do + create(:ci_build) + end + end end diff --git a/spec/models/ci/build_trace_metadata_spec.rb b/spec/models/ci/build_trace_metadata_spec.rb index 5e4645c5dc4..120e4289da2 100644 --- a/spec/models/ci/build_trace_metadata_spec.rb +++ b/spec/models/ci/build_trace_metadata_spec.rb @@ -88,14 +88,16 @@ RSpec.describe Ci::BuildTraceMetadata do describe '#track_archival!' do let(:trace_artifact) { create(:ci_job_artifact) } let(:metadata) { create(:ci_build_trace_metadata) } + let(:checksum) { SecureRandom.hex } it 'stores the artifact id and timestamp' do expect(metadata.trace_artifact_id).to be_nil - metadata.track_archival!(trace_artifact.id) + metadata.track_archival!(trace_artifact.id, checksum) metadata.reload expect(metadata.trace_artifact_id).to eq(trace_artifact.id) + expect(metadata.checksum).to eq(checksum) expect(metadata.archived_at).to be_like_time(Time.current) end end @@ -131,4 +133,29 @@ RSpec.describe Ci::BuildTraceMetadata do end end end + + describe '#remote_checksum_valid?' do + using RSpec::Parameterized::TableSyntax + + let(:metadata) do + build(:ci_build_trace_metadata, + checksum: checksum, + remote_checksum: remote_checksum) + end + + subject { metadata.remote_checksum_valid? } + + where(:checksum, :remote_checksum, :result) do + nil | nil | false + nil | 'a' | false + 'a' | nil | false + 'a' | 'b' | false + 'b' | 'a' | false + 'a' | 'a' | true + end + + with_them do + it { is_expected.to eq(result) } + end + end end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 1007d64438f..98b55ccb76b 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -35,6 +35,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do it { is_expected.to have_many(:sourced_pipelines) } it { is_expected.to have_many(:triggered_pipelines) } it { is_expected.to have_many(:pipeline_artifacts) } + it { is_expected.to have_many(:package_build_infos).dependent(:nullify).inverse_of(:pipeline) } + it { is_expected.to have_many(:package_file_build_infos).dependent(:nullify).inverse_of(:pipeline) } it { is_expected.to have_one(:chat_data) } it { is_expected.to have_one(:source_pipeline) } @@ -1219,32 +1221,6 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do %w(test success), %w(deploy running)]) end - - context 'when commit status is retried' do - let!(:old_commit_status) do - create(:commit_status, pipeline: pipeline, - stage: 'build', - name: 'mac', - stage_idx: 0, - status: 'success') - end - - context 'when FF ci_remove_update_retried_from_process_pipeline is disabled' do - before do - stub_feature_flags(ci_remove_update_retried_from_process_pipeline: false) - - Ci::ProcessPipelineService - .new(pipeline) - .execute - end - - it 'ignores the previous state' do - expect(statuses).to eq([%w(build success), - %w(test success), - %w(deploy running)]) - end - end - end end context 'when there is a stage with warnings' do @@ -2906,121 +2882,30 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end end - describe '#execute_hooks' do + describe 'hooks trigerring' 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, pipeline_events: enabled) - end - - before do - WebHookWorker.drain - end - - context 'with pipeline hooks enabled' do - let(:enabled) { true } - - before do - stub_full_request(hook.url, method: :post) - end - - context 'with multiple builds', :sidekiq_inline do - context 'when build is queued' do - before do - build_a.reload.enqueue - build_b.reload.enqueue - end - - it 'receives a pending event once' do - expect(WebMock).to have_requested_pipeline_hook('pending').once - end - - it 'builds hook data once' do - create(:pipelines_email_integration) - - expect(Gitlab::DataBuilder::Pipeline).to receive(:build).once.and_call_original - - pipeline.execute_hooks - end - end - - context 'when build is run' do - before do - build_a.reload.enqueue - build_a.reload.run! - build_b.reload.enqueue - build_b.reload.run! - end - - it 'receives a running event once' do - expect(WebMock).to have_requested_pipeline_hook('running').once - end - end - - context 'when all builds succeed' do - before do - build_a.success - - # We have to reload build_b as this is in next stage and it gets triggered by PipelineProcessWorker - build_b.reload.success - end - - it 'receives a success event once' do - expect(WebMock).to have_requested_pipeline_hook('success').once - end - end + %i[ + enqueue + request_resource + prepare + run + skip + drop + succeed + cancel + block + delay + ].each do |action| + context "when pipeline action is #{action}" do + let(:pipeline_action) { action } - context 'when stage one failed' do - let!(:build_b) { create_build('b', 1) } - - before do - build_a.drop - end + it 'schedules a new PipelineHooksWorker job' do + expect(PipelineHooksWorker).to receive(:perform_async).with(pipeline.id) - it 'receives a failed event once' do - expect(WebMock).to have_requested_pipeline_hook('failed').once - end + pipeline.reload.public_send(pipeline_action) end - - def have_requested_pipeline_hook(status) - have_requested(:post, stubbed_hostname(hook.url)).with do |req| - json_body = Gitlab::Json.parse(req.body) - json_body['object_attributes']['status'] == status && - json_body['builds'].length == 2 - end - end - end - end - - context 'with pipeline hooks disabled' do - let(:enabled) { false } - - before do - build_a.enqueue - build_b.enqueue - end - - it 'did not execute pipeline_hook after touched' do - expect(WebMock).not_to have_requested(:post, hook.url) end - - it 'does not build hook data' do - expect(Gitlab::DataBuilder::Pipeline).not_to receive(:build) - - pipeline.execute_hooks - end - end - - def create_build(name, stage_idx) - create(:ci_build, - :created, - pipeline: pipeline, - name: name, - stage: "stage:#{stage_idx}", - stage_idx: stage_idx) end end diff --git a/spec/models/ci/processable_spec.rb b/spec/models/ci/processable_spec.rb index 0a43f785598..ac1a8247aaa 100644 --- a/spec/models/ci/processable_spec.rb +++ b/spec/models/ci/processable_spec.rb @@ -147,13 +147,20 @@ RSpec.describe Ci::Processable do end it 'releases a resource when build finished' do - expect(build.resource_group).to receive(:release_resource_from).with(build).and_call_original + expect(build.resource_group).to receive(:release_resource_from).with(build).and_return(true).and_call_original expect(Ci::ResourceGroups::AssignResourceFromResourceGroupWorker).to receive(:perform_async).with(build.resource_group_id) build.enqueue_waiting_for_resource! build.success! end + it 're-checks the resource group even if the processable does not retain a resource' do + expect(build.resource_group).to receive(:release_resource_from).with(build).and_return(false).and_call_original + expect(Ci::ResourceGroups::AssignResourceFromResourceGroupWorker).to receive(:perform_async).with(build.resource_group_id) + + build.success! + end + context 'when build has prerequisites' do before do allow(build).to receive(:any_unmet_prerequisites?) { true } diff --git a/spec/models/ci/resource_group_spec.rb b/spec/models/ci/resource_group_spec.rb index 50a786419f2..aae16157fbf 100644 --- a/spec/models/ci/resource_group_spec.rb +++ b/spec/models/ci/resource_group_spec.rb @@ -85,4 +85,61 @@ RSpec.describe Ci::ResourceGroup do end end end + + describe '#upcoming_processables' do + subject { resource_group.upcoming_processables } + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:pipeline_1) { create(:ci_pipeline, project: project) } + let_it_be(:pipeline_2) { create(:ci_pipeline, project: project) } + + let!(:resource_group) { create(:ci_resource_group, process_mode: process_mode, project: project) } + + Ci::HasStatus::STATUSES_ENUM.keys.each do |status| + let!("build_1_#{status}") { create(:ci_build, pipeline: pipeline_1, status: status, resource_group: resource_group) } + let!("build_2_#{status}") { create(:ci_build, pipeline: pipeline_2, status: status, resource_group: resource_group) } + end + + context 'when process mode is unordered' do + let(:process_mode) { :unordered } + + it 'returns correct jobs in an indeterministic order' do + expect(subject).to contain_exactly(build_1_waiting_for_resource, build_2_waiting_for_resource) + end + end + + context 'when process mode is oldest_first' do + let(:process_mode) { :oldest_first } + + it 'returns correct jobs in a specific order' do + expect(subject[0]).to eq(build_1_waiting_for_resource) + expect(subject[1..2]).to contain_exactly(build_1_created, build_1_scheduled) + expect(subject[3]).to eq(build_2_waiting_for_resource) + expect(subject[4..5]).to contain_exactly(build_2_created, build_2_scheduled) + end + end + + context 'when process mode is newest_first' do + let(:process_mode) { :newest_first } + + it 'returns correct jobs in a specific order' do + expect(subject[0]).to eq(build_2_waiting_for_resource) + expect(subject[1..2]).to contain_exactly(build_2_created, build_2_scheduled) + expect(subject[3]).to eq(build_1_waiting_for_resource) + expect(subject[4..5]).to contain_exactly(build_1_created, build_1_scheduled) + end + end + + context 'when process mode is unknown' do + let(:process_mode) { :unordered } + + before do + resource_group.update_column(:process_mode, 3) + end + + it 'returns empty' do + is_expected.to be_empty + end + end + end end diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index 31e854c852e..826332268c5 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -5,6 +5,20 @@ require 'spec_helper' RSpec.describe Ci::Runner do it_behaves_like 'having unique enum values' + describe 'groups association' do + # Due to other assoctions such as projects this whole spec is allowed to + # generate cross-database queries. So we have this temporary spec to + # validate that at least groups association does not generate cross-DB + # queries. + it 'does not create a cross-database query' do + runner = create(:ci_runner, :group) + + with_cross_joins_prevented do + expect(runner.groups.count).to eq(1) + end + end + end + describe 'validation' do it { is_expected.to validate_presence_of(:access_level) } it { is_expected.to validate_presence_of(:runner_type) } @@ -257,7 +271,7 @@ RSpec.describe Ci::Runner do expect(subject).to be_truthy expect(runner).to be_project_type - expect(runner.projects).to eq([project]) + expect(runner.runner_projects.pluck(:project_id)).to match_array([project.id]) expect(runner.only_for?(project)).to be_truthy end end @@ -383,10 +397,7 @@ RSpec.describe Ci::Runner do it 'sticks the runner to the primary and calls the original method' do runner = create(:ci_runner) - allow(Gitlab::Database::LoadBalancing).to receive(:enable?) - .and_return(true) - - expect(Gitlab::Database::LoadBalancing::Sticking).to receive(:stick) + expect(ApplicationRecord.sticking).to receive(:stick) .with(:runner, runner.id) expect(Gitlab::Workhorse).to receive(:set_key_and_notify) @@ -724,7 +735,7 @@ RSpec.describe Ci::Runner do context 'with invalid runner' do before do - runner.projects = [] + runner.runner_projects.delete_all end it 'still updates redis cache and database' do diff --git a/spec/models/clusters/agents/group_authorization_spec.rb b/spec/models/clusters/agents/group_authorization_spec.rb index 2a99fb26e3f..baeb8f5464e 100644 --- a/spec/models/clusters/agents/group_authorization_spec.rb +++ b/spec/models/clusters/agents/group_authorization_spec.rb @@ -7,4 +7,10 @@ RSpec.describe Clusters::Agents::GroupAuthorization do it { is_expected.to belong_to(:group).class_name('::Group').required } it { expect(described_class).to validate_jsonb_schema(['config']) } + + describe '#config_project' do + let(:record) { create(:agent_group_authorization) } + + it { expect(record.config_project).to eq(record.agent.project) } + end end diff --git a/spec/models/clusters/agents/implicit_authorization_spec.rb b/spec/models/clusters/agents/implicit_authorization_spec.rb index 69aa55a350e..2d6c3ddb426 100644 --- a/spec/models/clusters/agents/implicit_authorization_spec.rb +++ b/spec/models/clusters/agents/implicit_authorization_spec.rb @@ -9,6 +9,6 @@ RSpec.describe Clusters::Agents::ImplicitAuthorization do it { expect(subject.agent).to eq(agent) } it { expect(subject.agent_id).to eq(agent.id) } - it { expect(subject.project).to eq(agent.project) } + it { expect(subject.config_project).to eq(agent.project) } it { expect(subject.config).to be_nil } end diff --git a/spec/models/clusters/agents/project_authorization_spec.rb b/spec/models/clusters/agents/project_authorization_spec.rb index 134c70739ac..9ba259356c7 100644 --- a/spec/models/clusters/agents/project_authorization_spec.rb +++ b/spec/models/clusters/agents/project_authorization_spec.rb @@ -7,4 +7,10 @@ RSpec.describe Clusters::Agents::ProjectAuthorization do it { is_expected.to belong_to(:project).class_name('Project').required } it { expect(described_class).to validate_jsonb_schema(['config']) } + + describe '#config_project' do + let(:record) { create(:agent_project_authorization) } + + it { expect(record.config_project).to eq(record.agent.project) } + end end diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb index 43e2eab3b9d..788430d53d3 100644 --- a/spec/models/clusters/applications/runner_spec.rb +++ b/spec/models/clusters/applications/runner_spec.rb @@ -96,8 +96,9 @@ RSpec.describe Clusters::Applications::Runner do it 'creates a project runner' do subject + runner_projects = Project.where(id: runner.runner_projects.pluck(:project_id)) expect(runner).to be_project_type - expect(runner.projects).to eq [project] + expect(runner_projects).to match_array [project] end end diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 63fe6923630..ac0ae17f8f7 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -799,7 +799,7 @@ eos describe '#work_in_progress?' do [ 'squash! ', 'fixup! ', 'wip: ', 'WIP: ', '[WIP] ', - 'draft: ', 'Draft - ', '[Draft] ', '(draft) ', 'Draft: ' + 'draft: ', '[Draft] ', '(draft) ', 'Draft: ' ].each do |wip_prefix| it "detects the '#{wip_prefix}' prefix" do commit.message = "#{wip_prefix}#{commit.message}" @@ -814,22 +814,18 @@ eos expect(commit).to be_work_in_progress end - it "detects WIP for a commit just saying 'draft'" do + it "does not detect WIP for a commit just saying 'draft'" do commit.message = "draft" - expect(commit).to be_work_in_progress - end - - it "doesn't detect WIP for a commit that begins with 'FIXUP! '" do - commit.message = "FIXUP! #{commit.message}" - expect(commit).not_to be_work_in_progress end - it "doesn't detect WIP for words starting with WIP" do - commit.message = "Wipout #{commit.message}" + ["FIXUP!", "Draft - ", "Wipeout"].each do |draft_prefix| + it "doesn't detect '#{draft_prefix}' at the start of the title as a draft" do + commit.message = "#{draft_prefix} #{commit.message}" - expect(commit).not_to be_work_in_progress + expect(commit).not_to be_work_in_progress + end end end diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index 7134a387e65..20afddd8470 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -123,6 +123,16 @@ RSpec.describe CommitStatus do end end + describe '.scheduled_at_before' do + let!(:never_scheduled) { create(:commit_status) } + let!(:stale_scheduled) { create(:commit_status, scheduled_at: 1.day.ago) } + let!(:fresh_scheduled) { create(:commit_status, scheduled_at: 1.minute.ago) } + + subject { CommitStatus.scheduled_at_before(1.hour.ago) } + + it { is_expected.to contain_exactly(stale_scheduled) } + end + describe '#processed' do subject { commit_status.processed } diff --git a/spec/models/concerns/bulk_insert_safe_spec.rb b/spec/models/concerns/bulk_insert_safe_spec.rb index 209ee1264d5..172986c142c 100644 --- a/spec/models/concerns/bulk_insert_safe_spec.rb +++ b/spec/models/concerns/bulk_insert_safe_spec.rb @@ -17,6 +17,7 @@ RSpec.describe BulkInsertSafe do t.binary :sha_value, null: false, limit: 20 t.jsonb :jsonb_value, null: false t.belongs_to :bulk_insert_parent_item, foreign_key: true, null: true + t.timestamps null: true t.index :name, unique: true end @@ -179,29 +180,26 @@ RSpec.describe BulkInsertSafe do end context 'with returns option set' do + let(:items) { bulk_insert_item_class.valid_list(1) } + + subject(:bulk_insert) { bulk_insert_item_class.bulk_insert!(items, returns: returns) } + context 'when is set to :ids' do - it 'return an array with the primary key values for all inserted records' do - items = bulk_insert_item_class.valid_list(1) + let(:returns) { :ids } - expect(bulk_insert_item_class.bulk_insert!(items, returns: :ids)).to contain_exactly(a_kind_of(Integer)) - end + it { is_expected.to contain_exactly(a_kind_of(Integer)) } end context 'when is set to nil' do - it 'returns an empty array' do - items = bulk_insert_item_class.valid_list(1) + let(:returns) { nil } - expect(bulk_insert_item_class.bulk_insert!(items, returns: nil)).to eq([]) - end + it { is_expected.to eq([]) } end - context 'when is set to anything else' do - it 'raises an error' do - items = bulk_insert_item_class.valid_list(1) + context 'when is set to a list of attributes' do + let(:returns) { [:id, :sha_value] } - expect { bulk_insert_item_class.bulk_insert!([items], returns: [:id, :name]) } - .to raise_error(ArgumentError, "returns needs to be :ids or nil") - end + it { is_expected.to contain_exactly([a_kind_of(Integer), '2fd4e1c67a2d28fced849ee1bb76e7391b93eb12']) } end end end @@ -228,10 +226,20 @@ RSpec.describe BulkInsertSafe do end describe '.bulk_upsert!' do + subject(:bulk_upsert) { bulk_insert_item_class.bulk_upsert!([new_object], unique_by: %w[name]) } + it 'updates existing object' do - bulk_insert_item_class.bulk_upsert!([new_object], unique_by: %w[name]) + expect { bulk_upsert }.to change { existing_object.reload.secret_value }.to('new value') + end - expect(existing_object.reload.secret_value).to eq('new value') + context 'when the `created_at` attribute is provided' do + before do + new_object.created_at = 10.days.from_now + end + + it 'does not change the existing `created_at` value' do + expect { bulk_upsert }.not_to change { existing_object.reload.created_at } + end end end end @@ -250,7 +258,7 @@ RSpec.describe BulkInsertSafe do it 'successfully inserts an item' do expect(ActiveRecord::InsertAll).to receive(:new) .with( - bulk_insert_items_with_composite_pk_class, [new_object.as_json], on_duplicate: :raise, returning: false, unique_by: %w[id name] + bulk_insert_items_with_composite_pk_class.insert_all_proxy_class, [new_object.as_json], on_duplicate: :raise, returning: false, unique_by: %w[id name] ).and_call_original expect { bulk_insert_items_with_composite_pk_class.bulk_insert!([new_object]) }.to( diff --git a/spec/models/concerns/checksummable_spec.rb b/spec/models/concerns/checksummable_spec.rb index 3a0387333e8..93a65605b50 100644 --- a/spec/models/concerns/checksummable_spec.rb +++ b/spec/models/concerns/checksummable_spec.rb @@ -13,11 +13,19 @@ RSpec.describe Checksummable do end end - describe ".hexdigest" do + describe ".sha256_hexdigest" do it 'returns the SHA256 sum of the file' do expected = Digest::SHA256.file(__FILE__).hexdigest - expect(subject.hexdigest(__FILE__)).to eq(expected) + expect(subject.sha256_hexdigest(__FILE__)).to eq(expected) + end + end + + describe ".md5_hexdigest" do + it 'returns the MD5 sum of the file' do + expected = Digest::MD5.file(__FILE__).hexdigest + + expect(subject.md5_hexdigest(__FILE__)).to eq(expected) end end end diff --git a/spec/models/concerns/ci/has_status_spec.rb b/spec/models/concerns/ci/has_status_spec.rb index 0709a050056..9dfc7d84f89 100644 --- a/spec/models/concerns/ci/has_status_spec.rb +++ b/spec/models/concerns/ci/has_status_spec.rb @@ -363,6 +363,18 @@ RSpec.describe Ci::HasStatus do it_behaves_like 'not containing the job', status end end + + describe '.waiting_for_resource_or_upcoming' do + subject { CommitStatus.waiting_for_resource_or_upcoming } + + %i[created scheduled waiting_for_resource].each do |status| + it_behaves_like 'containing the job', status + end + + %i[running failed success canceled].each do |status| + it_behaves_like 'not containing the job', status + end + end end describe '::DEFAULT_STATUS' do diff --git a/spec/models/concerns/vulnerability_finding_helpers_spec.rb b/spec/models/concerns/vulnerability_finding_helpers_spec.rb new file mode 100644 index 00000000000..023ecccb520 --- /dev/null +++ b/spec/models/concerns/vulnerability_finding_helpers_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe VulnerabilityFindingHelpers do + let(:cls) do + Class.new do + include VulnerabilityFindingHelpers + + attr_accessor :report_type + + def initialize(report_type) + @report_type = report_type + end + end + end + + describe '#requires_manual_resolution?' do + it 'returns false if the finding does not require manual resolution' do + expect(cls.new('sast').requires_manual_resolution?).to eq(false) + end + + it 'returns true when the finding requires manual resolution' do + expect(cls.new('secret_detection').requires_manual_resolution?).to eq(true) + end + end +end diff --git a/spec/models/customer_relations/contact_spec.rb b/spec/models/customer_relations/contact_spec.rb index b19554dd67e..298d5db3ab9 100644 --- a/spec/models/customer_relations/contact_spec.rb +++ b/spec/models/customer_relations/contact_spec.rb @@ -6,6 +6,7 @@ RSpec.describe CustomerRelations::Contact, type: :model do describe 'associations' do it { is_expected.to belong_to(:group) } it { is_expected.to belong_to(:organization).optional } + it { is_expected.to have_and_belong_to_many(:issues) } end describe 'validations' do diff --git a/spec/models/dependency_proxy/blob_spec.rb b/spec/models/dependency_proxy/blob_spec.rb index 3797f6184fe..3c54d3126a8 100644 --- a/spec/models/dependency_proxy/blob_spec.rb +++ b/spec/models/dependency_proxy/blob_spec.rb @@ -2,17 +2,16 @@ require 'spec_helper' RSpec.describe DependencyProxy::Blob, type: :model do + it_behaves_like 'ttl_expirable' + describe 'relationships' do it { is_expected.to belong_to(:group) } end - it_behaves_like 'having unique enum values' - describe 'validations' do it { is_expected.to validate_presence_of(:group) } it { is_expected.to validate_presence_of(:file) } it { is_expected.to validate_presence_of(:file_name) } - it { is_expected.to validate_presence_of(:status) } end describe '.total_size' do diff --git a/spec/models/dependency_proxy/image_ttl_group_policy_spec.rb b/spec/models/dependency_proxy/image_ttl_group_policy_spec.rb index 2906ea7b774..9f6358e1286 100644 --- a/spec/models/dependency_proxy/image_ttl_group_policy_spec.rb +++ b/spec/models/dependency_proxy/image_ttl_group_policy_spec.rb @@ -20,4 +20,13 @@ RSpec.describe DependencyProxy::ImageTtlGroupPolicy, type: :model do it { is_expected.to validate_numericality_of(:ttl).allow_nil.is_greater_than(0) } end end + + describe '.enabled' do + it 'returns policies that are enabled' do + enabled_policy = create(:image_ttl_group_policy) + create(:image_ttl_group_policy, :disabled) + + expect(described_class.enabled).to contain_exactly(enabled_policy) + end + end end diff --git a/spec/models/dependency_proxy/manifest_spec.rb b/spec/models/dependency_proxy/manifest_spec.rb index 2a085b3613b..e7f0889345a 100644 --- a/spec/models/dependency_proxy/manifest_spec.rb +++ b/spec/models/dependency_proxy/manifest_spec.rb @@ -2,18 +2,17 @@ require 'spec_helper' RSpec.describe DependencyProxy::Manifest, type: :model do + it_behaves_like 'ttl_expirable' + describe 'relationships' do it { is_expected.to belong_to(:group) } end - it_behaves_like 'having unique enum values' - describe 'validations' do it { is_expected.to validate_presence_of(:group) } it { is_expected.to validate_presence_of(:file) } it { is_expected.to validate_presence_of(:file_name) } it { is_expected.to validate_presence_of(:digest) } - it { is_expected.to validate_presence_of(:status) } end describe 'file is being stored' do diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index a0e5e9cbfe4..f9a05fbb06f 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -456,18 +456,6 @@ RSpec.describe Deployment do end end - describe 'with_deployable' do - subject { described_class.with_deployable } - - it 'retrieves deployments with deployable builds' do - with_deployable = create(:deployment) - create(:deployment, deployable: nil) - create(:deployment, deployable_type: 'CommitStatus', deployable_id: non_existing_record_id) - - is_expected.to contain_exactly(with_deployable) - end - end - describe 'visible' do subject { described_class.visible } @@ -613,6 +601,26 @@ RSpec.describe Deployment do end end + describe '.builds' do + let!(:deployment1) { create(:deployment) } + let!(:deployment2) { create(:deployment) } + let!(:deployment3) { create(:deployment) } + + subject { described_class.builds } + + it 'retrieves builds for the deployments' do + is_expected.to match_array( + [deployment1.deployable, deployment2.deployable, deployment3.deployable]) + end + + it 'does not fetch the null deployable_ids' do + deployment3.update!(deployable_id: nil, deployable_type: nil) + + is_expected.to match_array( + [deployment1.deployable, deployment2.deployable]) + end + end + describe '#previous_deployment' do using RSpec::Parameterized::TableSyntax @@ -757,7 +765,7 @@ RSpec.describe Deployment do expect(Deployments::LinkMergeRequestWorker).to receive(:perform_async) expect(Deployments::HooksWorker).to receive(:perform_async) - deploy.update_status('success') + expect(deploy.update_status('success')).to eq(true) end it 'updates finished_at when transitioning to a finished status' do @@ -767,6 +775,139 @@ RSpec.describe Deployment do expect(deploy.read_attribute(:finished_at)).to eq(Time.current) end end + + it 'tracks an exception if an invalid status transition is detected' do + expect(Gitlab::ErrorTracking) + .to receive(:track_exception) + .with(instance_of(described_class::StatusUpdateError), deployment_id: deploy.id) + + expect(deploy.update_status('running')).to eq(false) + end + + it 'tracks an exception if an invalid argument' do + expect(Gitlab::ErrorTracking) + .to receive(:track_exception) + .with(instance_of(described_class::StatusUpdateError), deployment_id: deploy.id) + + expect(deploy.update_status('created')).to eq(false) + end + end + + describe '#sync_status_with' do + subject { deployment.sync_status_with(ci_build) } + + let_it_be(:project) { create(:project, :repository) } + + let(:deployment) { create(:deployment, project: project, status: deployment_status) } + let(:ci_build) { create(:ci_build, project: project, status: build_status) } + + shared_examples_for 'synchronizing deployment' do + it 'changes deployment status' do + expect(Gitlab::ErrorTracking).not_to receive(:track_exception) + + is_expected.to eq(true) + + expect(deployment.status).to eq(build_status.to_s) + expect(deployment.errors).to be_empty + end + end + + shared_examples_for 'gracefully handling error' do + it 'tracks an exception' do + expect(Gitlab::ErrorTracking).to receive(:track_exception).with( + instance_of(described_class::StatusSyncError), + deployment_id: deployment.id, + build_id: ci_build.id) + + is_expected.to eq(false) + + expect(deployment.status).to eq(deployment_status.to_s) + expect(deployment.errors.full_messages).to include(error_message) + end + end + + shared_examples_for 'ignoring build' do + it 'does not change deployment status' do + expect(Gitlab::ErrorTracking).not_to receive(:track_exception) + + is_expected.to eq(false) + + expect(deployment.status).to eq(deployment_status.to_s) + expect(deployment.errors).to be_empty + end + end + + context 'with created deployment' do + let(:deployment_status) { :created } + + context 'with running build' do + let(:build_status) { :running } + + it_behaves_like 'synchronizing deployment' + end + + context 'with finished build' do + let(:build_status) { :success } + + it_behaves_like 'synchronizing deployment' + end + + context 'with unrelated build' do + let(:build_status) { :waiting_for_resource } + + it_behaves_like 'ignoring build' + end + end + + context 'with running deployment' do + let(:deployment_status) { :running } + + context 'with running build' do + let(:build_status) { :running } + + it_behaves_like 'gracefully handling error' do + let(:error_message) { %Q{Status cannot transition via \"run\"} } + end + end + + context 'with finished build' do + let(:build_status) { :success } + + it_behaves_like 'synchronizing deployment' + end + + context 'with unrelated build' do + let(:build_status) { :waiting_for_resource } + + it_behaves_like 'ignoring build' + end + end + + context 'with finished deployment' do + let(:deployment_status) { :success } + + context 'with running build' do + let(:build_status) { :running } + + it_behaves_like 'gracefully handling error' do + let(:error_message) { %Q{Status cannot transition via \"run\"} } + end + end + + context 'with finished build' do + let(:build_status) { :success } + + it_behaves_like 'gracefully handling error' do + let(:error_message) { %Q{Status cannot transition via \"succeed\"} } + end + end + + context 'with unrelated build' do + let(:build_status) { :waiting_for_resource } + + it_behaves_like 'ignoring build' + end + end end describe '#valid_sha' do diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index e3e9d1f7a71..08c639957d3 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -801,38 +801,6 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do expect(query_count).to eq(0) end end - - context 'when the feature for disable_join is disabled' do - let(:pipeline) { create(:ci_pipeline, project: project) } - let(:ci_build) { create(:ci_build, project: project, pipeline: pipeline) } - - before do - stub_feature_flags(environment_last_visible_pipeline_disable_joins: false) - create(:deployment, :failed, project: project, environment: environment, deployable: ci_build) - end - - context 'for preload' do - it 'executes the original association instead of override' do - environment.reload - ActiveRecord::Associations::Preloader.new.preload(environment, [last_visible_deployable: []]) - - expect_any_instance_of(Deployment).not_to receive(:deployable) - - query_count = ActiveRecord::QueryRecorder.new do - expect(subject.id).to eq(ci_build.id) - end.count - - expect(query_count).to eq(0) - end - end - - context 'for direct call' do - it 'executes the original association instead of override' do - expect_any_instance_of(Deployment).not_to receive(:deployable) - expect(subject.id).to eq(ci_build.id) - end - end - end end describe '#last_visible_pipeline' do @@ -963,40 +931,6 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do expect(query_count).to eq(0) end end - - context 'when the feature for disable_join is disabled' do - let(:pipeline) { create(:ci_pipeline, project: project) } - let(:ci_build) { create(:ci_build, project: project, pipeline: pipeline) } - - before do - stub_feature_flags(environment_last_visible_pipeline_disable_joins: false) - create(:deployment, :failed, project: project, environment: environment, deployable: ci_build) - end - - subject { environment.last_visible_pipeline } - - context 'for preload' do - it 'executes the original association instead of override' do - environment.reload - ActiveRecord::Associations::Preloader.new.preload(environment, [last_visible_pipeline: []]) - - expect_any_instance_of(Ci::Build).not_to receive(:pipeline) - - query_count = ActiveRecord::QueryRecorder.new do - expect(subject.id).to eq(pipeline.id) - end.count - - expect(query_count).to eq(0) - end - end - - context 'for direct call' do - it 'executes the original association instead of override' do - expect_any_instance_of(Ci::Build).not_to receive(:pipeline) - expect(subject.id).to eq(pipeline.id) - end - end - end end describe '#upcoming_deployment' do diff --git a/spec/models/error_tracking/error_spec.rb b/spec/models/error_tracking/error_spec.rb index 5543392b624..9b8a81c6372 100644 --- a/spec/models/error_tracking/error_spec.rb +++ b/spec/models/error_tracking/error_spec.rb @@ -81,6 +81,13 @@ RSpec.describe ErrorTracking::Error, type: :model do end describe '#to_sentry_detailed_error' do - it { expect(error.to_sentry_detailed_error).to be_kind_of(Gitlab::ErrorTracking::DetailedError) } + let_it_be(:event) { create(:error_tracking_error_event, error: error) } + + subject { error.to_sentry_detailed_error } + + it { is_expected.to be_kind_of(Gitlab::ErrorTracking::DetailedError) } + it { expect(subject.integrated).to be_truthy } + it { expect(subject.first_release_version).to eq('db853d7') } + it { expect(subject.last_release_version).to eq('db853d7') } end end 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 29255e53fcf..d17541b4a6c 100644 --- a/spec/models/error_tracking/project_error_tracking_setting_spec.rb +++ b/spec/models/error_tracking/project_error_tracking_setting_spec.rb @@ -79,6 +79,46 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do end end + describe 'Callbacks' do + describe 'after_save :create_client_key!' do + subject { build(:project_error_tracking_setting, :integrated, project: project) } + + context 'no client key yet' do + it 'creates a new client key' do + expect { subject.save! }.to change { ErrorTracking::ClientKey.count }.by(1) + end + + context 'sentry backend' do + before do + subject.integrated = false + end + + it 'does not create a new client key' do + expect { subject.save! }.not_to change { ErrorTracking::ClientKey.count } + end + end + + context 'feature disabled' do + before do + subject.enabled = false + end + + it 'does not create a new client key' do + expect { subject.save! }.not_to change { ErrorTracking::ClientKey.count } + end + end + end + + context 'client key already exists' do + let!(:client_key) { create(:error_tracking_client_key, project: project) } + + it 'does not create a new client key' do + expect { subject.save! }.not_to change { ErrorTracking::ClientKey.count } + end + end + end + end + describe '.extract_sentry_external_url' do subject { described_class.extract_sentry_external_url(sentry_url) } @@ -494,4 +534,10 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do it { expect(subject.sentry_enabled).to eq(sentry_enabled) } end end + + describe '#gitlab_dsn' do + let!(:client_key) { create(:error_tracking_client_key, project: project) } + + it { expect(subject.gitlab_dsn).to eq(client_key.sentry_dsn) } + end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index e8aebe35302..e88abc21ef2 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -36,6 +36,7 @@ RSpec.describe Group do it { is_expected.to have_many(:debian_distributions).class_name('Packages::Debian::GroupDistribution').dependent(:destroy) } it { is_expected.to have_many(:daily_build_group_report_results).class_name('Ci::DailyBuildGroupReportResult') } it { is_expected.to have_many(:group_callouts).class_name('Users::GroupCallout').with_foreign_key(:group_id) } + it { is_expected.to have_many(:bulk_import_exports).class_name('BulkImports::Export') } describe '#members & #requesters' do let(:requester) { create(:user) } @@ -2369,7 +2370,7 @@ RSpec.describe Group do let_it_be(:project) { create(:project, group: group, shared_runners_enabled: true) } let_it_be(:project_2) { create(:project, group: sub_group_2, shared_runners_enabled: true) } - subject { group.update_shared_runners_setting!('disabled_and_unoverridable') } + subject { group.update_shared_runners_setting!(Namespace::SR_DISABLED_AND_UNOVERRIDABLE) } it 'disables shared Runners for all descendant groups and projects' do expect { subject_and_reload(group, sub_group, sub_group_2, project, project_2) } @@ -2395,7 +2396,7 @@ RSpec.describe Group do end context 'disabled_with_override' do - subject { group.update_shared_runners_setting!('disabled_with_override') } + subject { group.update_shared_runners_setting!(Namespace::SR_DISABLED_WITH_OVERRIDE) } context 'top level group' do let_it_be(:group) { create(:group, :shared_runners_disabled) } @@ -2607,17 +2608,29 @@ RSpec.describe Group do end describe '.ids_with_disabled_email' do - let!(:parent_1) { create(:group, emails_disabled: true) } - let!(:child_1) { create(:group, parent: parent_1) } + let_it_be(:parent_1) { create(:group, emails_disabled: true) } + let_it_be(:child_1) { create(:group, parent: parent_1) } - let!(:parent_2) { create(:group, emails_disabled: false) } - let!(:child_2) { create(:group, parent: parent_2) } + let_it_be(:parent_2) { create(:group, emails_disabled: false) } + let_it_be(:child_2) { create(:group, parent: parent_2) } - let!(:other_group) { create(:group, emails_disabled: false) } + let_it_be(:other_group) { create(:group, emails_disabled: false) } - subject(:group_ids_where_email_is_disabled) { described_class.ids_with_disabled_email([child_1, child_2, other_group]) } + shared_examples 'returns namespaces with disabled email' do + subject(:group_ids_where_email_is_disabled) { described_class.ids_with_disabled_email([child_1, child_2, other_group]) } - it { is_expected.to eq(Set.new([child_1.id])) } + it { is_expected.to eq(Set.new([child_1.id])) } + end + + it_behaves_like 'returns namespaces with disabled email' + + context 'when feature flag :linear_group_ancestor_scopes is disabled' do + before do + stub_feature_flags(linear_group_ancestor_scopes: false) + end + + it_behaves_like 'returns namespaces with disabled email' + end end describe '.timelogs' do diff --git a/spec/models/instance_configuration_spec.rb b/spec/models/instance_configuration_spec.rb index 551e6e7572c..cc0b69e3526 100644 --- a/spec/models/instance_configuration_spec.rb +++ b/spec/models/instance_configuration_spec.rb @@ -31,6 +31,23 @@ RSpec.describe InstanceConfiguration do expect(result.size).to eq(InstanceConfiguration::SSH_ALGORITHMS.size) end + it 'includes all algorithms' do + stub_pub_file(pub_file) + + result = subject.settings[:ssh_algorithms_hashes] + + expect(result.map { |a| a[:name] }).to match_array(%w(DSA ECDSA ED25519 RSA)) + end + + it 'does not include disabled algorithm' do + Gitlab::CurrentSettings.current_application_settings.update!(dsa_key_restriction: ApplicationSetting::FORBIDDEN_KEY_VALUE) + stub_pub_file(pub_file) + + result = subject.settings[:ssh_algorithms_hashes] + + expect(result.map { |a| a[:name] }).to match_array(%w(ECDSA ED25519 RSA)) + end + def pub_file(exist: true) path = exist ? 'spec/fixtures/ssh_host_example_key.pub' : 'spec/fixtures/ssh_host_example_key.pub.random' @@ -175,6 +192,9 @@ RSpec.describe InstanceConfiguration do throttle_authenticated_packages_api_enabled: true, throttle_authenticated_packages_api_requests_per_period: 1011, throttle_authenticated_packages_api_period_in_seconds: 1012, + throttle_authenticated_git_lfs_enabled: true, + throttle_authenticated_git_lfs_requests_per_period: 1022, + throttle_authenticated_git_lfs_period_in_seconds: 1023, issues_create_limit: 1013, notes_create_limit: 1014, project_export_limit: 1015, @@ -196,6 +216,7 @@ RSpec.describe InstanceConfiguration do expect(rate_limits[:protected_paths]).to eq({ enabled: true, requests_per_period: 1007, period_in_seconds: 1008 }) expect(rate_limits[:unauthenticated_packages_api]).to eq({ enabled: false, requests_per_period: 1009, period_in_seconds: 1010 }) expect(rate_limits[:authenticated_packages_api]).to eq({ enabled: true, requests_per_period: 1011, period_in_seconds: 1012 }) + expect(rate_limits[:authenticated_git_lfs_api]).to eq({ enabled: true, requests_per_period: 1022, period_in_seconds: 1023 }) expect(rate_limits[:issue_creation]).to eq({ enabled: true, requests_per_period: 1013, period_in_seconds: 60 }) expect(rate_limits[:note_creation]).to eq({ enabled: true, requests_per_period: 1014, period_in_seconds: 60 }) expect(rate_limits[:project_export]).to eq({ enabled: true, requests_per_period: 1015, period_in_seconds: 60 }) diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb index 8a06f7fac99..1a83d948fcf 100644 --- a/spec/models/integration_spec.rb +++ b/spec/models/integration_spec.rb @@ -14,7 +14,6 @@ RSpec.describe Integration do it { is_expected.to have_one(:service_hook).inverse_of(:integration).with_foreign_key(:service_id) } it { is_expected.to have_one(:issue_tracker_data).autosave(true).inverse_of(:integration).with_foreign_key(:service_id).class_name('Integrations::IssueTrackerData') } it { is_expected.to have_one(:jira_tracker_data).autosave(true).inverse_of(:integration).with_foreign_key(:service_id).class_name('Integrations::JiraTrackerData') } - it { is_expected.to have_one(:open_project_tracker_data).autosave(true).inverse_of(:integration).with_foreign_key(:service_id).class_name('Integrations::OpenProjectTrackerData') } end describe 'validations' do diff --git a/spec/models/integrations/open_project_spec.rb b/spec/models/integrations/open_project_spec.rb deleted file mode 100644 index 789911acae8..00000000000 --- a/spec/models/integrations/open_project_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Integrations::OpenProject do - describe 'Validations' do - context 'when integration is active' do - before do - subject.active = true - end - - it { is_expected.to validate_presence_of(:url) } - it { is_expected.to validate_presence_of(:token) } - it { is_expected.to validate_presence_of(:project_identifier_code) } - - it_behaves_like 'issue tracker integration URL attribute', :url - it_behaves_like 'issue tracker integration URL attribute', :api_url - end - - context 'when integration is inactive' do - before do - subject.active = false - end - - it { is_expected.not_to validate_presence_of(:url) } - it { is_expected.not_to validate_presence_of(:token) } - it { is_expected.not_to validate_presence_of(:project_identifier_code) } - end - end -end diff --git a/spec/models/integrations/open_project_tracker_data_spec.rb b/spec/models/integrations/open_project_tracker_data_spec.rb deleted file mode 100644 index 41c913f978c..00000000000 --- a/spec/models/integrations/open_project_tracker_data_spec.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Integrations::OpenProjectTrackerData do - describe 'associations' do - it { is_expected.to belong_to(:integration) } - end - - describe 'closed_status_id' do - it 'returns the set value' do - expect(build(:open_project_tracker_data).closed_status_id).to eq('15') - end - - it 'returns the default value if not set' do - expect(build(:open_project_tracker_data, closed_status_id: nil).closed_status_id).to eq('13') - end - end -end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 1747972e8ae..4319407706e 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -34,6 +34,7 @@ RSpec.describe Issue do it { is_expected.to have_many(:issue_email_participants) } it { is_expected.to have_many(:timelogs).autosave(true) } it { is_expected.to have_one(:incident_management_issuable_escalation_status) } + it { is_expected.to have_and_belong_to_many(:customer_relations_contacts) } describe 'versions.most_recent' do it 'returns the most recent version' do @@ -222,17 +223,15 @@ RSpec.describe Issue do end end - describe '#order_by_position_and_priority' do + describe '#order_by_relative_position' do let(:project) { reusable_project } - let(:p1) { create(:label, title: 'P1', project: project, priority: 1) } - let(:p2) { create(:label, title: 'P2', project: project, priority: 2) } - let!(:issue1) { create(:labeled_issue, project: project, labels: [p1]) } - let!(:issue2) { create(:labeled_issue, project: project, labels: [p2]) } + let!(:issue1) { create(:issue, project: project) } + let!(:issue2) { create(:issue, project: project) } let!(:issue3) { create(:issue, project: project, relative_position: -200) } let!(:issue4) { create(:issue, project: project, relative_position: -100) } it 'returns ordered list' do - expect(project.issues.order_by_position_and_priority) + expect(project.issues.order_by_relative_position) .to match [issue3, issue4, issue1, issue2] end end @@ -1505,6 +1504,26 @@ RSpec.describe Issue do end end + describe '#supports_move_and_clone?' do + let_it_be(:project) { create(:project) } + let_it_be_with_refind(:issue) { create(:incident, project: project) } + + where(:issue_type, :supports_move_and_clone) do + :issue | true + :incident | true + end + + with_them do + before do + issue.update!(issue_type: issue_type) + end + + it do + expect(issue.supports_move_and_clone?).to eq(supports_move_and_clone) + end + end + end + describe '#email_participants_emails' do let_it_be(:issue) { create(:issue) } diff --git a/spec/models/loose_foreign_keys/deleted_record_spec.rb b/spec/models/loose_foreign_keys/deleted_record_spec.rb deleted file mode 100644 index db2f8b4d2d3..00000000000 --- a/spec/models/loose_foreign_keys/deleted_record_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe LooseForeignKeys::DeletedRecord do - let_it_be(:deleted_record_1) { described_class.create!(created_at: 1.day.ago, deleted_table_name: 'projects', deleted_table_primary_key_value: 5) } - let_it_be(:deleted_record_2) { described_class.create!(created_at: 3.days.ago, deleted_table_name: 'projects', deleted_table_primary_key_value: 1) } - let_it_be(:deleted_record_3) { described_class.create!(created_at: 5.days.ago, deleted_table_name: 'projects', deleted_table_primary_key_value: 3) } - let_it_be(:deleted_record_4) { described_class.create!(created_at: 10.days.ago, deleted_table_name: 'projects', deleted_table_primary_key_value: 1) } # duplicate - - # skip created_at because it gets truncated after insert - def map_attributes(records) - records.pluck(:deleted_table_name, :deleted_table_primary_key_value) - end - - describe 'partitioning strategy' do - it 'has retain_non_empty_partitions option' do - expect(described_class.partitioning_strategy.retain_non_empty_partitions).to eq(true) - end - end - - describe '.load_batch' do - it 'loads records and orders them by creation date' do - records = described_class.load_batch(4) - - expect(map_attributes(records)).to eq([['projects', 1], ['projects', 3], ['projects', 1], ['projects', 5]]) - end - - it 'supports configurable batch size' do - records = described_class.load_batch(2) - - expect(map_attributes(records)).to eq([['projects', 1], ['projects', 3]]) - end - end - - describe '.delete_records' do - it 'deletes exactly one record' do - described_class.delete_records([deleted_record_2]) - - expect(described_class.count).to eq(3) - expect(described_class.find_by(created_at: deleted_record_2.created_at)).to eq(nil) - end - - it 'deletes two records' do - described_class.delete_records([deleted_record_2, deleted_record_4]) - - expect(described_class.count).to eq(2) - end - - it 'deletes all records' do - described_class.delete_records([deleted_record_1, deleted_record_2, deleted_record_3, deleted_record_4]) - - expect(described_class.count).to eq(0) - end - end -end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index 3f7f69ff34e..afe78adc547 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -7,11 +7,11 @@ RSpec.describe Member do using RSpec::Parameterized::TableSyntax - describe "Associations" do + describe 'Associations' do it { is_expected.to belong_to(:user) } end - describe "Validation" do + describe 'Validation' do subject { described_class.new(access_level: Member::GUEST) } it { is_expected.to validate_presence_of(:user) } @@ -28,7 +28,7 @@ RSpec.describe Member do subject { build(:project_member) } end - context "when an invite email is provided" do + context 'when an invite email is provided' do let_it_be(:project) { create(:project) } let(:member) { build(:project_member, source: project, invite_email: "user@example.com", user: nil) } @@ -37,34 +37,36 @@ RSpec.describe Member do expect(member).to be_valid end - it "requires a valid invite email" do + it 'requires a valid invite email' do member.invite_email = "nope" expect(member).not_to be_valid end - it "requires a unique invite email scoped to this source" do + it 'requires a unique invite email scoped to this source' do create(:project_member, source: member.source, invite_email: member.invite_email) expect(member).not_to be_valid end end - context "when an invite email is not provided" do + context 'when an invite email is not provided' do let(:member) { build(:project_member) } - it "requires a user" do + it 'requires a user' do member.user = nil expect(member).not_to be_valid end - it "is valid otherwise" do + it 'is valid otherwise' do expect(member).to be_valid end end context 'with admin signup restrictions' do + let(:expected_message) { _('is not allowed for this group. Check with your administrator.') } + context 'when allowed domains for signup is enabled' do before do stub_application_setting(domain_allowlist: ['example.com']) @@ -74,7 +76,7 @@ RSpec.describe Member do member = build(:group_member, :invited, invite_email: 'info@gitlab.com') expect(member).not_to be_valid - expect(member.errors.messages[:user].first).to eq(_('domain is not authorized for sign-up.')) + expect(member.errors.messages[:user].first).to eq(expected_message) end end @@ -88,7 +90,7 @@ RSpec.describe Member do member = build(:group_member, :invited, invite_email: 'denylist@example.org') expect(member).not_to be_valid - expect(member.errors.messages[:user].first).to eq(_('is not from an allowed domain.')) + expect(member.errors.messages[:user].first).to eq(expected_message) end end @@ -102,18 +104,18 @@ RSpec.describe Member do member = build(:group_member, :invited, invite_email: 'info@gitlab.com') expect(member).not_to be_valid - expect(member.errors.messages[:user].first).to eq(_('is not allowed. Try again with a different email address, or contact your GitLab admin.')) + expect(member.errors.messages[:user].first).to eq(expected_message) end end end - context "when a child member inherits its access level" do + context 'when a child member inherits its access level' do let(:user) { create(:user) } let(:member) { create(:group_member, :developer, user: user) } let(:child_group) { create(:group, parent: member.group) } let(:child_member) { build(:group_member, group: child_group, user: user) } - it "requires a higher level" do + it 'requires a higher level' do child_member.access_level = GroupMember::REPORTER child_member.validate @@ -123,7 +125,7 @@ RSpec.describe Member do # Membership in a subgroup confers certain access rights, such as being # able to merge or push code to protected branches. - it "is valid with an equal level" do + it 'is valid with an equal level' do child_member.access_level = GroupMember::DEVELOPER child_member.validate @@ -131,7 +133,7 @@ RSpec.describe Member do expect(child_member).to be_valid end - it "is valid with a higher level" do + it 'is valid with a higher level' do child_member.access_level = GroupMember::MAINTAINER child_member.validate @@ -167,6 +169,8 @@ RSpec.describe Member do describe 'Scopes & finders' do let_it_be(:project) { create(:project, :public) } let_it_be(:group) { create(:group) } + let_it_be(:blocked_pending_approval_user) { create(:user, :blocked_pending_approval ) } + let_it_be(:blocked_pending_approval_project_member) { create(:project_member, :invited, :developer, project: project, invite_email: blocked_pending_approval_user.email) } before_all do @owner_user = create(:user).tap { |u| group.add_owner(u) } @@ -536,9 +540,28 @@ RSpec.describe Member do it { is_expected.to eq [example_member] } end end + + describe '.with_invited_user_state' do + subject(:with_invited_user_state) { described_class.with_invited_user_state } + + it { is_expected.to include @owner } + it { is_expected.to include @maintainer } + it { is_expected.to include @invited_member } + it { is_expected.to include @accepted_invite_member } + it { is_expected.to include @requested_member } + it { is_expected.to include @accepted_request_member } + + context 'with invited pending members' do + it 'includes invited user state' do + invited_pending_members = with_invited_user_state.select { |m| m.invited_user_state.present? } + expect(invited_pending_members.count).to eq 1 + expect(invited_pending_members).to include blocked_pending_approval_project_member + end + end + end end - describe "Delegate methods" do + describe 'Delegate methods' do it { is_expected.to respond_to(:user_name) } it { is_expected.to respond_to(:user_email) } end @@ -608,29 +631,29 @@ RSpec.describe Member do end end - describe "#accept_invite!" do + describe '#accept_invite!' do let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } let(:user) { create(:user) } - it "resets the invite token" do + it 'resets the invite token' do member.accept_invite!(user) expect(member.invite_token).to be_nil end - it "sets the invite accepted timestamp" do + it 'sets the invite accepted timestamp' do member.accept_invite!(user) expect(member.invite_accepted_at).not_to be_nil end - it "sets the user" do + it 'sets the user' do member.accept_invite!(user) expect(member.user).to eq(user) end - it "calls #after_accept_invite" do + it 'calls #after_accept_invite' do expect(member).to receive(:after_accept_invite) member.accept_invite!(user) @@ -657,26 +680,26 @@ RSpec.describe Member do end end - describe "#decline_invite!" do + describe '#decline_invite!' do let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } - it "destroys the member" do + it 'destroys the member' do member.decline_invite! expect(member).to be_destroyed end - it "calls #after_decline_invite" do + it 'calls #after_decline_invite' do expect(member).to receive(:after_decline_invite) member.decline_invite! end end - describe "#generate_invite_token" do + describe '#generate_invite_token' do let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } - it "sets the invite token" do + it 'sets the invite token' do expect { member.generate_invite_token }.to change { member.invite_token } end end @@ -684,12 +707,12 @@ RSpec.describe Member do describe 'generate invite token on create' do let!(:member) { build(:project_member, invite_email: "user@example.com") } - it "sets the invite token" do + it 'sets the invite token' do expect { member.save! }.to change { member.invite_token }.to(kind_of(String)) end context 'when invite was already accepted' do - it "does not set invite token" do + it 'does not set invite token' do member.invite_accepted_at = 1.day.ago expect { member.save! }.not_to change { member.invite_token }.from(nil) @@ -744,7 +767,7 @@ RSpec.describe Member do end end - describe "#invite_to_unknown_user?" do + describe '#invite_to_unknown_user?' do subject { member.invite_to_unknown_user? } let(:member) { create(:project_member, invite_email: "user@example.com", invite_token: '1234', user: user) } @@ -762,7 +785,7 @@ RSpec.describe Member do end end - describe "destroying a record", :delete do + describe 'destroying a record', :delete, :sidekiq_inline do it "refreshes user's authorized projects" do project = create(:project, :private) user = create(:user) diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index 1704d5adb96..ca846cf9e8e 100644 --- a/spec/models/members/project_member_spec.rb +++ b/spec/models/members/project_member_spec.rb @@ -244,11 +244,16 @@ RSpec.describe ProjectMember do project.add_user(user, Gitlab::Access::GUEST) end - it 'changes access level' do + it 'changes access level', :sidekiq_inline do expect { action }.to change { user.can?(:guest_access, project) }.from(true).to(false) end - it_behaves_like 'calls AuthorizedProjectUpdate::ProjectRecalculatePerUserService to recalculate authorizations' + it 'calls AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker to recalculate authorizations' do + expect(AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker).to receive(:perform_async).with(project.id, user.id) + + action + end + it_behaves_like 'calls AuthorizedProjectUpdate::UserRefreshFromReplicaWorker with a delay to update project authorizations' end @@ -298,7 +303,7 @@ RSpec.describe ProjectMember do project.add_user(user, Gitlab::Access::GUEST) end - it 'changes access level' do + it 'changes access level', :sidekiq_inline do expect { action }.to change { user.can?(:guest_access, project) }.from(true).to(false) end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 06ca88644b7..d871453e062 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1348,7 +1348,7 @@ RSpec.describe MergeRequest, factory_default: :keep do [ 'WIP:', 'WIP: ', '[WIP]', '[WIP] ', ' [WIP] WIP: [WIP] WIP:', - 'draft:', 'Draft: ', '[Draft]', '[DRAFT] ', 'Draft - ' + 'draft:', 'Draft: ', '[Draft]', '[DRAFT] ' ].each do |wip_prefix| it "detects the '#{wip_prefix}' prefix" do subject.title = "#{wip_prefix}#{subject.title}" @@ -1357,16 +1357,27 @@ RSpec.describe MergeRequest, factory_default: :keep do end end + [ + "WIP ", "(WIP)", + "draft", "Draft", "Draft -", "draft - ", "Draft ", "draft " + ].each do |draft_prefix| + it "doesn't detect '#{draft_prefix}' at the start of the title as a draft" do + subject.title = "#{draft_prefix}#{subject.title}" + + expect(subject.work_in_progress?).to eq false + end + end + it "detects merge request title just saying 'wip'" do subject.title = "wip" expect(subject.work_in_progress?).to eq true end - it "detects merge request title just saying 'draft'" do + it "does not detect merge request title just saying 'draft'" do subject.title = "draft" - expect(subject.work_in_progress?).to eq true + expect(subject.work_in_progress?).to eq false end it 'does not detect WIP in the middle of the title' do @@ -1428,7 +1439,7 @@ RSpec.describe MergeRequest, factory_default: :keep do [ 'WIP:', 'WIP: ', '[WIP]', '[WIP] ', '[WIP] WIP: [WIP] WIP:', - 'draft:', 'Draft: ', '[Draft]', '[DRAFT] ', 'Draft - ' + 'draft:', 'Draft: ', '[Draft]', '[DRAFT] ' ].each do |wip_prefix| it "removes the '#{wip_prefix}' prefix" do wipless_title = subject.title @@ -3078,7 +3089,7 @@ RSpec.describe MergeRequest, factory_default: :keep do end end - describe '#mergeable_state?' do + shared_examples 'for mergeable_state' do subject { create(:merge_request) } it 'checks if merge request can be merged' do @@ -3119,33 +3130,61 @@ RSpec.describe MergeRequest, factory_default: :keep do end context 'when failed' do - context 'when #mergeable_ci_state? is false' do - before do - allow(subject).to receive(:mergeable_ci_state?) { false } - end + shared_examples 'failed skip_ci_check' do + context 'when #mergeable_ci_state? is false' do + before do + allow(subject).to receive(:mergeable_ci_state?) { false } + end - it 'returns false' do - expect(subject.mergeable_state?).to be_falsey + it 'returns false' do + expect(subject.mergeable_state?).to be_falsey + end + + it 'returns true when skipping ci check' do + expect(subject.mergeable_state?(skip_ci_check: true)).to be(true) + end end - it 'returns true when skipping ci check' do - expect(subject.mergeable_state?(skip_ci_check: true)).to be(true) + context 'when #mergeable_discussions_state? is false' do + before do + allow(subject).to receive(:mergeable_discussions_state?) { false } + end + + it 'returns false' do + expect(subject.mergeable_state?).to be_falsey + end + + it 'returns true when skipping discussions check' do + expect(subject.mergeable_state?(skip_discussions_check: true)).to be(true) + end end end - context 'when #mergeable_discussions_state? is false' do + context 'when improved_mergeability_checks is on' do + it_behaves_like 'failed skip_ci_check' + end + + context 'when improved_mergeability_checks is off' do before do - allow(subject).to receive(:mergeable_discussions_state?) { false } + stub_feature_flags(improved_mergeability_checks: false) end - it 'returns false' do - expect(subject.mergeable_state?).to be_falsey - end + it_behaves_like 'failed skip_ci_check' + end + end + end - it 'returns true when skipping discussions check' do - expect(subject.mergeable_state?(skip_discussions_check: true)).to be(true) - end + describe '#mergeable_state?' do + context 'when merge state caching is on' do + it_behaves_like 'for mergeable_state' + end + + context 'when merge state caching is off' do + before do + stub_feature_flags(mergeability_caching: false) end + + it_behaves_like 'for mergeable_state' end end diff --git a/spec/models/namespace/traversal_hierarchy_spec.rb b/spec/models/namespace/traversal_hierarchy_spec.rb index 2cd66f42458..d7b0ee888c0 100644 --- a/spec/models/namespace/traversal_hierarchy_spec.rb +++ b/spec/models/namespace/traversal_hierarchy_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Namespace::TraversalHierarchy, type: :model do - let_it_be(:root, reload: true) { create(:group, :with_hierarchy) } + let!(:root) { create(:group, :with_hierarchy) } describe '.for_namespace' do let(:hierarchy) { described_class.for_namespace(group) } @@ -62,7 +62,12 @@ RSpec.describe Namespace::TraversalHierarchy, type: :model do it { expect(hierarchy.incorrect_traversal_ids).to be_empty } - it_behaves_like 'hierarchy with traversal_ids' + it_behaves_like 'hierarchy with traversal_ids' do + before do + subject + end + end + it_behaves_like 'locked row' do let(:recorded_queries) { ActiveRecord::QueryRecorder.new } let(:row) { root } diff --git a/spec/models/namespace_setting_spec.rb b/spec/models/namespace_setting_spec.rb index c1cc8fc3e88..429727c2360 100644 --- a/spec/models/namespace_setting_spec.rb +++ b/spec/models/namespace_setting_spec.rb @@ -11,6 +11,8 @@ RSpec.describe NamespaceSetting, type: :model do it { is_expected.to belong_to(:namespace) } end + it { is_expected.to define_enum_for(:jobs_to_be_done).with_values([:basics, :move_repository, :code_storage, :exploring, :ci, :other]).with_suffix } + describe "validations" do describe "#default_branch_name_content" do let_it_be(:group) { create(:group) } diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 51a26d82daa..c201d89947e 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -7,6 +7,10 @@ RSpec.describe Namespace do include GitHelpers include ReloadHelpers + let_it_be(:group_sti_name) { Group.sti_name } + let_it_be(:project_sti_name) { Namespaces::ProjectNamespace.sti_name } + let_it_be(:user_sti_name) { Namespaces::UserNamespace.sti_name } + let!(:namespace) { create(:namespace, :with_namespace_settings) } let(:gitlab_shell) { Gitlab::Shell.new } let(:repository_storage) { 'default' } @@ -38,20 +42,22 @@ RSpec.describe Namespace do context 'validating the parent of a namespace' do using RSpec::Parameterized::TableSyntax + # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands where(:parent_type, :child_type, :error) do - nil | 'User' | nil - nil | 'Group' | nil - nil | 'Project' | 'must be set for a project namespace' - 'Project' | 'User' | 'project namespace cannot be the parent of another namespace' - 'Project' | 'Group' | 'project namespace cannot be the parent of another namespace' - 'Project' | 'Project' | 'project namespace cannot be the parent of another namespace' - 'Group' | 'User' | 'cannot not be used for user namespace' - 'Group' | 'Group' | nil - 'Group' | 'Project' | nil - 'User' | 'User' | 'cannot not be used for user namespace' - 'User' | 'Group' | 'user namespace cannot be the parent of another namespace' - 'User' | 'Project' | nil - end + nil | ref(:user_sti_name) | nil + nil | ref(:group_sti_name) | nil + nil | ref(:project_sti_name) | 'must be set for a project namespace' + ref(:project_sti_name) | ref(:user_sti_name) | 'project namespace cannot be the parent of another namespace' + ref(:project_sti_name) | ref(:group_sti_name) | 'project namespace cannot be the parent of another namespace' + ref(:project_sti_name) | ref(:project_sti_name) | 'project namespace cannot be the parent of another namespace' + ref(:group_sti_name) | ref(:user_sti_name) | 'cannot not be used for user namespace' + ref(:group_sti_name) | ref(:group_sti_name) | nil + ref(:group_sti_name) | ref(:project_sti_name) | nil + ref(:user_sti_name) | ref(:user_sti_name) | 'cannot not be used for user namespace' + ref(:user_sti_name) | ref(:group_sti_name) | 'user namespace cannot be the parent of another namespace' + ref(:user_sti_name) | ref(:project_sti_name) | nil + end + # rubocop:enable Lint/BinaryOperatorWithIdenticalOperands with_them do it 'validates namespace parent' do @@ -127,39 +133,77 @@ RSpec.describe Namespace do end context 'top-level group' do - let(:group) { build(:group, path: 'tree') } + let(:group) { build(:namespace, path: 'tree') } it { expect(group).to be_valid } end end - describe '1 char path length' do - it 'does not allow to create one' do - namespace = build(:namespace, path: 'j') + describe 'path validator' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:parent) { create(:namespace) } - expect(namespace).not_to be_valid - expect(namespace.errors[:path].first).to eq('is too short (minimum is 2 characters)') + # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands + where(:namespace_type, :path, :valid) do + ref(:project_sti_name) | 'j' | true + ref(:project_sti_name) | 'path.' | true + ref(:project_sti_name) | 'blob' | false + ref(:group_sti_name) | 'j' | false + ref(:group_sti_name) | 'path.' | false + ref(:group_sti_name) | 'blob' | true + ref(:user_sti_name) | 'j' | false + ref(:user_sti_name) | 'path.' | false + ref(:user_sti_name) | 'blob' | true end + # rubocop:enable Lint/BinaryOperatorWithIdenticalOperands - it 'does not allow to update one' do - namespace = create(:namespace) - namespace.update(path: 'j') + with_them do + it 'validates namespace path' do + parent_namespace = parent if namespace_type == Namespaces::ProjectNamespace.sti_name + namespace = build(:namespace, type: namespace_type, parent: parent_namespace, path: path) - expect(namespace).not_to be_valid - expect(namespace.errors[:path].first).to eq('is too short (minimum is 2 characters)') + expect(namespace.valid?).to be(valid) + end end + end - it 'allows updating other attributes for existing record' do - namespace = build(:namespace, path: 'j', owner: create(:user)) - namespace.save(validate: false) - namespace.reload + describe '1 char path length' do + context 'with user namespace' do + let(:namespace) { build(:namespace) } - expect(namespace.path).to eq('j') + it 'does not allow to update path to single char' do + namespace.save! - namespace.update(name: 'something new') + namespace.path = 'j' - expect(namespace).to be_valid - expect(namespace.name).to eq('something new') + expect(namespace).not_to be_valid + expect(namespace.errors[:path].first).to eq('is too short (minimum is 2 characters)') + end + + it 'allows updating other attributes for existing record' do + namespace.save! + namespace.update_attribute(:path, 'j') + namespace.reload + + expect(namespace.path).to eq('j') + + namespace.update(name: 'something new') + + expect(namespace).to be_valid + expect(namespace.name).to eq('something new') + end + end + + context 'with project namespace' do + let(:namespace) { build(:project_namespace) } + + it 'allows to update path to single char' do + namespace = create(:project_namespace) + namespace.update(path: 'j') + + expect(namespace).to be_valid + end end end end @@ -170,55 +214,53 @@ RSpec.describe Namespace do let(:namespace) { Namespace.find(create(:namespace, type: namespace_type, parent: parent).id) } context 'creating a Group' do - let(:namespace_type) { 'Group' } + let(:namespace_type) { group_sti_name } - it 'is valid' do + it 'is the correct type of namespace' do expect(namespace).to be_a(Group) expect(namespace.kind).to eq('group') - expect(namespace.group?).to be_truthy + expect(namespace.group_namespace?).to be_truthy end end context 'creating a ProjectNamespace' do - let(:namespace_type) { 'Project' } + let(:namespace_type) { project_sti_name } let(:parent) { create(:group) } - it 'is valid' do + it 'is the correct type of namespace' do expect(Namespace.find(namespace.id)).to be_a(Namespaces::ProjectNamespace) expect(namespace.kind).to eq('project') - expect(namespace.project?).to be_truthy + expect(namespace.project_namespace?).to be_truthy end end context 'creating a UserNamespace' do - let(:namespace_type) { 'User' } + let(:namespace_type) { user_sti_name } - it 'is valid' do - # TODO: We create a normal Namespace until - # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68894 is ready - expect(Namespace.find(namespace.id)).to be_a(Namespace) + it 'is the correct type of namespace' do + expect(Namespace.find(namespace.id)).to be_a(Namespaces::UserNamespace) expect(namespace.kind).to eq('user') - expect(namespace.user?).to be_truthy + expect(namespace.user_namespace?).to be_truthy end end context 'creating a default Namespace' do let(:namespace_type) { nil } - it 'is valid' do + it 'is the correct type of namespace' do expect(Namespace.find(namespace.id)).to be_a(Namespace) expect(namespace.kind).to eq('user') - expect(namespace.user?).to be_truthy + expect(namespace.user_namespace?).to be_truthy end end context 'creating an unknown Namespace type' do let(:namespace_type) { 'One' } - it 'defaults to a Namespace' do + it 'creates a default Namespace' do expect(Namespace.find(namespace.id)).to be_a(Namespace) expect(namespace.kind).to eq('user') - expect(namespace.user?).to be_truthy + expect(namespace.user_namespace?).to be_truthy end end end @@ -257,6 +299,15 @@ RSpec.describe Namespace do expect(described_class.sorted_by_similarity_and_parent_id_desc('Namespace')).to eq([namespace2, namespace1, namespace2sub, namespace1sub, namespace]) end end + + describe '.without_project_namespaces' do + let_it_be(:user_namespace) { create(:user_namespace) } + let_it_be(:project_namespace) { create(:project_namespace) } + + it 'excludes project namespaces' do + expect(described_class.without_project_namespaces).to match_array([namespace, namespace1, namespace2, namespace1sub, namespace2sub, user_namespace, project_namespace.parent]) + end + end end describe 'delegate' do @@ -428,9 +479,9 @@ RSpec.describe Namespace do end describe '.search' do - 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(:first_group) { create(:group, name: 'my first namespace', path: 'old-path') } + let_it_be(:parent_group) { create(:group, name: 'my parent namespace', path: 'parent-path') } + let_it_be(:second_group) { create(:group, name: 'my second namespace', path: 'new-path', parent: parent_group) } 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 @@ -1558,8 +1609,8 @@ RSpec.describe Namespace do end end - describe '#user?' do - subject { namespace.user? } + describe '#user_namespace?' do + subject { namespace.user_namespace? } context 'when type is a user' do let(:user) { create(:user) } @@ -1745,10 +1796,10 @@ RSpec.describe Namespace do using RSpec::Parameterized::TableSyntax where(:shared_runners_enabled, :allow_descendants_override_disabled_shared_runners, :shared_runners_setting) do - true | true | 'enabled' - true | false | 'enabled' - false | true | 'disabled_with_override' - false | false | 'disabled_and_unoverridable' + true | true | Namespace::SR_ENABLED + true | false | Namespace::SR_ENABLED + false | true | Namespace::SR_DISABLED_WITH_OVERRIDE + false | false | Namespace::SR_DISABLED_AND_UNOVERRIDABLE end with_them do @@ -1764,15 +1815,15 @@ RSpec.describe Namespace do using RSpec::Parameterized::TableSyntax where(:shared_runners_enabled, :allow_descendants_override_disabled_shared_runners, :other_setting, :result) do - true | true | 'enabled' | false - true | true | 'disabled_with_override' | true - true | true | 'disabled_and_unoverridable' | true - false | true | 'enabled' | false - false | true | 'disabled_with_override' | false - false | true | 'disabled_and_unoverridable' | true - false | false | 'enabled' | false - false | false | 'disabled_with_override' | false - false | false | 'disabled_and_unoverridable' | false + true | true | Namespace::SR_ENABLED | false + true | true | Namespace::SR_DISABLED_WITH_OVERRIDE | true + true | true | Namespace::SR_DISABLED_AND_UNOVERRIDABLE | true + false | true | Namespace::SR_ENABLED | false + false | true | Namespace::SR_DISABLED_WITH_OVERRIDE | false + false | true | Namespace::SR_DISABLED_AND_UNOVERRIDABLE | true + false | false | Namespace::SR_ENABLED | false + false | false | Namespace::SR_DISABLED_WITH_OVERRIDE | false + false | false | Namespace::SR_DISABLED_AND_UNOVERRIDABLE | false end with_them do diff --git a/spec/models/namespaces/user_namespace_spec.rb b/spec/models/namespaces/user_namespace_spec.rb new file mode 100644 index 00000000000..7c00a597756 --- /dev/null +++ b/spec/models/namespaces/user_namespace_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Main user namespace functionality it still in `Namespace`, so most +# of the specs are in `namespace_spec.rb`. +# UserNamespace specific specs will end up being migrated here. +RSpec.describe Namespaces::UserNamespace, type: :model do + describe 'validations' do + it { is_expected.to validate_presence_of(:owner) } + end +end diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 5e3773513f1..0dd77967f25 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -108,6 +108,34 @@ RSpec.describe Note do end describe 'callbacks' do + describe '#keep_around_commit' do + let!(:noteable) { create(:issue) } + + it "calls #keep_around_commit normally" do + note = build(:note, project: noteable.project, noteable: noteable) + + expect(note).to receive(:keep_around_commit) + + note.save! + end + + it "skips #keep_around_commit if 'skip_keep_around_commits' is true" do + note = build(:note, project: noteable.project, noteable: noteable, skip_keep_around_commits: true) + + expect(note).not_to receive(:keep_around_commit) + + note.save! + end + + it "skips #keep_around_commit if 'importing' is true" do + note = build(:note, project: noteable.project, noteable: noteable, importing: true) + + expect(note).not_to receive(:keep_around_commit) + + note.save! + end + end + describe '#notify_after_create' do it 'calls #after_note_created on the noteable' do noteable = create(:issue) diff --git a/spec/models/operations/feature_flag_spec.rb b/spec/models/operations/feature_flag_spec.rb index d689632e2b4..e709470b312 100644 --- a/spec/models/operations/feature_flag_spec.rb +++ b/spec/models/operations/feature_flag_spec.rb @@ -13,7 +13,7 @@ RSpec.describe Operations::FeatureFlag do describe 'associations' do it { is_expected.to belong_to(:project) } - it { is_expected.to have_many(:scopes) } + it { is_expected.to have_many(:strategies) } end describe '.reference_pattern' do @@ -52,17 +52,6 @@ RSpec.describe Operations::FeatureFlag do it { is_expected.to define_enum_for(:version).with_values(new_version_flag: 2) } context 'a version 2 feature flag' do - it 'is invalid if associated with Operations::FeatureFlagScope models' do - project = create(:project) - feature_flag = described_class.new({ name: 'test', project: project, version: 2, - scopes_attributes: [{ environment_scope: '*', active: false }] }) - - expect(feature_flag.valid?).to eq(false) - expect(feature_flag.errors.messages).to eq({ - version_associations: ["version 2 feature flags may not have scopes"] - }) - end - it 'is valid if associated with Operations::FeatureFlags::Strategy models' do project = create(:project) feature_flag = described_class.create!({ name: 'test', project: project, version: 2, @@ -81,18 +70,6 @@ RSpec.describe Operations::FeatureFlag do end end - describe 'the default scope' do - let_it_be(:project) { create(:project) } - - context 'with a version 2 feature flag' do - it 'does not create a default scope' do - feature_flag = described_class.create!({ name: 'test', project: project, scopes_attributes: [], version: 2 }) - - expect(feature_flag.scopes).to eq([]) - end - end - end - describe '.enabled' do subject { described_class.enabled } @@ -187,26 +164,4 @@ RSpec.describe Operations::FeatureFlag do expect(subject.hook_attrs).to eq(hook_attrs) end end - - describe "#execute_hooks" do - let_it_be(:user) { create(:user) } - let_it_be(:project) { create(:project) } - let_it_be(:feature_flag) { create(:operations_feature_flag, project: project) } - - it 'does not execute the hook when feature_flag event is disabled' do - create(:project_hook, project: project, feature_flag_events: false) - expect(WebHookWorker).not_to receive(:perform_async) - - feature_flag.execute_hooks(user) - feature_flag.touch - end - - it 'executes hook when feature_flag event is enabled' do - hook = create(:project_hook, project: project, feature_flag_events: true) - expect(WebHookWorker).to receive(:perform_async).with(hook.id, an_instance_of(Hash), 'feature_flag_hooks') - - feature_flag.execute_hooks(user) - feature_flag.touch - end - end end diff --git a/spec/models/packages/helm/file_metadatum_spec.rb b/spec/models/packages/helm/file_metadatum_spec.rb index c7c17b157e4..995179b391d 100644 --- a/spec/models/packages/helm/file_metadatum_spec.rb +++ b/spec/models/packages/helm/file_metadatum_spec.rb @@ -31,8 +31,8 @@ RSpec.describe Packages::Helm::FileMetadatum, type: :model do it 'validates #channel', :aggregate_failures do is_expected.to validate_presence_of(:channel) - is_expected.to allow_value('a' * 63).for(:channel) - is_expected.not_to allow_value('a' * 64).for(:channel) + is_expected.to allow_value('a' * 255).for(:channel) + is_expected.not_to allow_value('a' * 256).for(:channel) is_expected.to allow_value('release').for(:channel) is_expected.to allow_value('my-repo').for(:channel) diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb index 99e5769fc1f..2573c01d686 100644 --- a/spec/models/packages/package_spec.rb +++ b/spec/models/packages/package_spec.rb @@ -1184,7 +1184,7 @@ RSpec.describe Packages::Package, type: :model do end context 'with an already existing build info' do - let_it_be(:build_info) { create(:packages_build_info, package: package, pipeline: pipeline) } + let_it_be(:build_info) { create(:package_build_info, package: package, pipeline: pipeline) } it 'does not create a build info' do expect { subject }.not_to change { ::Packages::BuildInfo.count } diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb index 7b997f0d4e1..2b6ed9a9927 100644 --- a/spec/models/pages_domain_spec.rb +++ b/spec/models/pages_domain_spec.rb @@ -94,7 +94,7 @@ RSpec.describe PagesDomain do with_them do it "is adds the expected errors" do - expect(pages_domain.errors.keys).to eq errors_on + expect(pages_domain.errors.attribute_names).to eq errors_on end end end @@ -155,7 +155,7 @@ RSpec.describe PagesDomain do it "adds error to certificate" do domain.valid? - expect(domain.errors.keys).to contain_exactly(:key, :certificate) + expect(domain.errors.attribute_names).to contain_exactly(:key, :certificate) end end @@ -165,7 +165,7 @@ RSpec.describe PagesDomain do domain.valid? - expect(domain.errors.keys).to contain_exactly(:key) + expect(domain.errors.attribute_names).to contain_exactly(:key) end end end @@ -287,6 +287,19 @@ RSpec.describe PagesDomain do it { is_expected.to be_truthy } end + + # The LetsEncrypt DST Root CA X3 expired on 2021-09-30, but the + # cross-sign in ISRG Root X1 enables it to function provided a chain + # of trust can be established with the system store. See: + # + # 1. https://community.letsencrypt.org/t/production-chain-changes/150739 + # 2. https://letsencrypt.org/2020/12/21/extending-android-compatibility.html + # 3. https://www.openssl.org/blog/blog/2021/09/13/LetsEncryptRootCertExpire/ + context 'with a LetsEncrypt bundle with an expired DST Root CA X3' do + let(:domain) { build(:pages_domain, :letsencrypt_expired_x3_root) } + + it { is_expected.to be_truthy } + end end describe '#expired?' do diff --git a/spec/models/preloaders/merge_requests_preloader_spec.rb b/spec/models/preloaders/merge_requests_preloader_spec.rb deleted file mode 100644 index 7108de2e491..00000000000 --- a/spec/models/preloaders/merge_requests_preloader_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Preloaders::MergeRequestsPreloader do - describe '#execute' do - let_it_be_with_refind(:merge_requests) { create_list(:merge_request, 3) } - let_it_be(:upvotes) { merge_requests.each { |m| create(:award_emoji, :upvote, awardable: m) } } - - it 'does not make n+1 queries' do - described_class.new(merge_requests).execute - - control = ActiveRecord::QueryRecorder.new(skip_cached: false) do - # expectations make sure the queries execute - merge_requests.each do |m| - expect(m.target_project.project_feature).not_to be_nil - expect(m.lazy_upvotes_count).to eq(1) - end - end - - # 1 query for BatchLoader to load all upvotes at once - expect(control.count).to eq(1) - end - - it 'runs extra queries without preloading' do - control = ActiveRecord::QueryRecorder.new(skip_cached: false) do - # expectations make sure the queries execute - merge_requests.each do |m| - expect(m.target_project.project_feature).not_to be_nil - expect(m.lazy_upvotes_count).to eq(1) - end - end - - # 4 queries per merge request = - # 1 to load merge request - # 1 to load project - # 1 to load project_feature - # 1 to load upvotes count - expect(control.count).to eq(4 * merge_requests.size) - end - end -end diff --git a/spec/models/product_analytics_event_spec.rb b/spec/models/product_analytics_event_spec.rb index 286729b8398..801e6dd5e10 100644 --- a/spec/models/product_analytics_event_spec.rb +++ b/spec/models/product_analytics_event_spec.rb @@ -36,17 +36,6 @@ RSpec.describe ProductAnalyticsEvent, type: :model do it { expect(described_class.count_by_graph('platform', 30.days)).to eq({ 'app' => 1, 'mobile' => 1, 'web' => 2 }) } end - describe '.by_category_and_action' do - let_it_be(:event) { create(:product_analytics_event, se_category: 'catA', se_action: 'actA') } - - before do - create(:product_analytics_event, se_category: 'catA', se_action: 'actB') - create(:product_analytics_event, se_category: 'catB', se_action: 'actA') - end - - it { expect(described_class.by_category_and_action('catA', 'actA')).to match_array([event]) } - end - describe '.count_collector_tstamp_by_day' do let_it_be(:time_now) { Time.zone.now } let_it_be(:time_ago) { Time.zone.now - 5.days } diff --git a/spec/models/project_feature_usage_spec.rb b/spec/models/project_feature_usage_spec.rb index 698c5374e88..3765a2b37a7 100644 --- a/spec/models/project_feature_usage_spec.rb +++ b/spec/models/project_feature_usage_spec.rb @@ -133,10 +133,8 @@ RSpec.describe ProjectFeatureUsage, type: :model do subject { project.feature_usage } - context 'database load balancing is configured', :db_load_balancing do + context 'database load balancing is configured' do before do - allow(ActiveRecord::Base).to receive(:connection).and_return(::Gitlab::Database::LoadBalancing.proxy) - ::Gitlab::Database::LoadBalancing::Session.clear_session end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 3989ddc31e8..10220448936 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -140,6 +140,7 @@ RSpec.describe Project, factory_default: :keep do it { is_expected.to have_many(:error_tracking_client_keys).class_name('ErrorTracking::ClientKey') } it { is_expected.to have_many(:pending_builds).class_name('Ci::PendingBuild') } it { is_expected.to have_many(:ci_feature_usages).class_name('Projects::CiFeatureUsage') } + it { is_expected.to have_many(:bulk_import_exports).class_name('BulkImports::Export') } # GitLab Pages it { is_expected.to have_many(:pages_domains) } @@ -494,23 +495,6 @@ RSpec.describe Project, factory_default: :keep do end end - describe '#merge_requests_author_approval' do - where(:attribute_value, :return_value) do - true | true - false | false - nil | false - end - - with_them do - let(:project) { create(:project, merge_requests_author_approval: attribute_value) } - - it 'returns expected value' do - expect(project.merge_requests_author_approval).to eq(return_value) - expect(project.merge_requests_author_approval?).to eq(return_value) - end - end - end - describe '#all_pipelines' do let_it_be(:project) { create(:project) } @@ -3066,7 +3050,7 @@ RSpec.describe Project, factory_default: :keep do let(:project) { create(:project) } it 'marks the location with project ID' do - expect(Gitlab::Database::LoadBalancing::Sticking).to receive(:mark_primary_write_location).with(:project, project.id) + expect(ApplicationRecord.sticking).to receive(:mark_primary_write_location).with(:project, project.id) project.mark_primary_write_location end @@ -6303,24 +6287,18 @@ RSpec.describe Project, factory_default: :keep do describe 'validation #changing_shared_runners_enabled_is_allowed' do where(:shared_runners_setting, :project_shared_runners_enabled, :valid_record) do - 'enabled' | true | true - 'enabled' | false | true - 'disabled_with_override' | true | true - 'disabled_with_override' | false | true - 'disabled_and_unoverridable' | true | false - 'disabled_and_unoverridable' | false | true + :shared_runners_enabled | true | true + :shared_runners_enabled | false | true + :disabled_with_override | true | true + :disabled_with_override | false | true + :disabled_and_unoverridable | true | false + :disabled_and_unoverridable | false | true end with_them do - let(:group) { create(:group) } + let(:group) { create(:group, shared_runners_setting) } let(:project) { build(:project, namespace: group, shared_runners_enabled: project_shared_runners_enabled) } - before do - allow_next_found_instance_of(Group) do |group| - allow(group).to receive(:shared_runners_setting).and_return(shared_runners_setting) - end - end - it 'validates the configuration' do expect(project.valid?).to eq(valid_record) @@ -7239,35 +7217,6 @@ RSpec.describe Project, factory_default: :keep do expect(project.reload.topics.map(&:name)).to eq(%w[topic1 topic2 topic3]) end end - - context 'during ExtractProjectTopicsIntoSeparateTable migration' do - before do - topic_a = ActsAsTaggableOn::Tag.find_or_create_by!(name: 'topicA') - topic_b = ActsAsTaggableOn::Tag.find_or_create_by!(name: 'topicB') - - project.reload.topics_acts_as_taggable = [topic_a, topic_b] - project.save! - project.reload - end - - it 'topic_list returns correct string array' do - expect(project.topic_list).to eq(%w[topicA topicB topic1 topic2 topic3]) - end - - it 'topics returns correct topic records' do - expect(project.topics.map(&:class)).to eq([ActsAsTaggableOn::Tag, ActsAsTaggableOn::Tag, Projects::Topic, Projects::Topic, Projects::Topic]) - expect(project.topics.map(&:name)).to eq(%w[topicA topicB topic1 topic2 topic3]) - end - - it 'topic_list= sets new topics and removes old topics' do - project.topic_list = 'new-topic1, new-topic2' - project.save! - project.reload - - expect(project.topics.map(&:class)).to eq([Projects::Topic, Projects::Topic]) - expect(project.topics.map(&:name)).to eq(%w[new-topic1 new-topic2]) - end - end end shared_examples 'all_runners' do diff --git a/spec/models/project_statistics_spec.rb b/spec/models/project_statistics_spec.rb index ba769e830fd..ead6238b2f4 100644 --- a/spec/models/project_statistics_spec.rb +++ b/spec/models/project_statistics_spec.rb @@ -294,15 +294,17 @@ RSpec.describe ProjectStatistics do describe '#update_lfs_objects_size' do let!(:lfs_object1) { create(:lfs_object, size: 23.megabytes) } let!(:lfs_object2) { create(:lfs_object, size: 34.megabytes) } + let!(:lfs_object3) { create(:lfs_object, size: 34.megabytes) } let!(:lfs_objects_project1) { create(:lfs_objects_project, project: project, lfs_object: lfs_object1) } let!(:lfs_objects_project2) { create(:lfs_objects_project, project: project, lfs_object: lfs_object2) } + let!(:lfs_objects_project3) { create(:lfs_objects_project, project: project, lfs_object: lfs_object3) } before do statistics.update_lfs_objects_size end it "stores the size of related LFS objects" do - expect(statistics.lfs_objects_size).to eq 57.megabytes + expect(statistics.lfs_objects_size).to eq 91.megabytes end end diff --git a/spec/models/projects/topic_spec.rb b/spec/models/projects/topic_spec.rb index 409dc932709..397c65f4d5c 100644 --- a/spec/models/projects/topic_spec.rb +++ b/spec/models/projects/topic_spec.rb @@ -3,12 +3,18 @@ require 'spec_helper' RSpec.describe Projects::Topic do - let_it_be(:topic, reload: true) { create(:topic) } + let_it_be(:topic, reload: true) { create(:topic, name: 'topic') } subject { topic } it { expect(subject).to be_valid } + describe 'modules' do + subject { described_class } + + it { is_expected.to include_module(Avatarable) } + end + describe 'associations' do it { is_expected.to have_many(:project_topics) } it { is_expected.to have_many(:projects) } @@ -18,5 +24,76 @@ RSpec.describe Projects::Topic do it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_uniqueness_of(:name) } it { is_expected.to validate_length_of(:name).is_at_most(255) } + it { is_expected.to validate_length_of(:description).is_at_most(1024) } + end + + describe 'scopes' do + describe 'order_by_total_projects_count' do + let!(:topic1) { create(:topic, name: 'topicB') } + let!(:topic2) { create(:topic, name: 'topicC') } + let!(:topic3) { create(:topic, name: 'topicA') } + let!(:project1) { create(:project, topic_list: 'topicC, topicA, topicB') } + let!(:project2) { create(:project, topic_list: 'topicC, topicA') } + let!(:project3) { create(:project, topic_list: 'topicC') } + + it 'sorts topics by total_projects_count' do + topics = described_class.order_by_total_projects_count + + expect(topics.map(&:name)).to eq(%w[topicC topicA topicB topic]) + end + end + + describe 'reorder_by_similarity' do + let!(:topic1) { create(:topic, name: 'my-topic') } + let!(:topic2) { create(:topic, name: 'other') } + let!(:topic3) { create(:topic, name: 'topic2') } + + it 'sorts topics by similarity' do + topics = described_class.reorder_by_similarity('topic') + + expect(topics.map(&:name)).to eq(%w[topic my-topic topic2 other]) + end + end + end + + describe '#search' do + it 'returns topics with a matching name' do + expect(described_class.search(topic.name)).to eq([topic]) + end + + it 'returns topics with a partially matching name' do + expect(described_class.search(topic.name[0..2])).to eq([topic]) + end + + it 'returns topics with a matching name regardless of the casing' do + expect(described_class.search(topic.name.upcase)).to eq([topic]) + end + end + + describe '#avatar_type' do + it "is true if avatar is image" do + topic.update_attribute(:avatar, 'uploads/avatar.png') + expect(topic.avatar_type).to be_truthy + end + + it "is false if avatar is html page" do + topic.update_attribute(:avatar, 'uploads/avatar.html') + topic.avatar_type + + expect(topic.errors.added?(:avatar, "file format is not supported. Please try one of the following supported formats: png, jpg, jpeg, gif, bmp, tiff, ico, webp")).to be true + end + end + + describe '#avatar_url' do + context 'when avatar file is uploaded' do + before do + topic.update!(avatar: fixture_file_upload("spec/fixtures/dk.png")) + end + + it 'shows correct avatar url' do + expect(topic.avatar_url).to eq(topic.avatar.url) + expect(topic.avatar_url(only_path: false)).to eq([Gitlab.config.gitlab.url, topic.avatar.url].join) + end + end end end diff --git a/spec/models/protected_branch_spec.rb b/spec/models/protected_branch_spec.rb index 019c01af672..587a9683a8e 100644 --- a/spec/models/protected_branch_spec.rb +++ b/spec/models/protected_branch_spec.rb @@ -308,4 +308,15 @@ RSpec.describe ProtectedBranch do expect(described_class.by_name('')).to be_empty end end + + describe '.get_ids_by_name' do + let(:branch_name) { 'branch_name' } + let!(:protected_branch) { create(:protected_branch, name: branch_name) } + let(:branch_id) { protected_branch.id } + + it 'returns the id for each protected branch matching name' do + expect(described_class.get_ids_by_name([branch_name])) + .to match_array([branch_id]) + end + end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index dc55214c1dd..7bad907cf90 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -71,7 +71,7 @@ RSpec.describe Repository do let(:feature_flag) { true } before do - stub_feature_flags(gitaly_tags_finder: feature_flag) + stub_feature_flags(tags_finder_gitaly: feature_flag) end context 'name_desc' do diff --git a/spec/models/snippet_repository_spec.rb b/spec/models/snippet_repository_spec.rb index 40a28b9e0cc..e8a933d2277 100644 --- a/spec/models/snippet_repository_spec.rb +++ b/spec/models/snippet_repository_spec.rb @@ -115,6 +115,7 @@ RSpec.describe SnippetRepository do allow(snippet).to receive(:repository).and_return(repo) allow(repo).to receive(:ls_files).and_return([]) allow(repo).to receive(:root_ref).and_return('master') + allow(repo).to receive(:empty?).and_return(false) end it 'infers the commit action based on the parameters if not present' do diff --git a/spec/models/upload_spec.rb b/spec/models/upload_spec.rb index 6bac5e31435..0ac684cd04c 100644 --- a/spec/models/upload_spec.rb +++ b/spec/models/upload_spec.rb @@ -242,4 +242,28 @@ RSpec.describe Upload do it { expect(subject.uploader_context).to match(a_hash_including(secret: 'secret', identifier: 'file.txt')) } end + + describe '#update_project_statistics' do + let_it_be(:project) { create(:project) } + + subject do + create(:upload, model: project) + end + + it 'updates project statistics when upload is added' do + expect(ProjectCacheWorker).to receive(:perform_async) + .with(project.id, [], [:uploads_size]) + + subject.save! + end + + it 'updates project statistics when upload is removed' do + subject.save! + + expect(ProjectCacheWorker).to receive(:perform_async) + .with(project.id, [], [:uploads_size]) + + subject.destroy! + end + end end diff --git a/spec/models/user_detail_spec.rb b/spec/models/user_detail_spec.rb index ba7ea3f7ce2..9189b9a1469 100644 --- a/spec/models/user_detail_spec.rb +++ b/spec/models/user_detail_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe UserDetail do it { is_expected.to belong_to(:user) } + it { is_expected.to define_enum_for(:registration_objective).with_values([:basics, :move_repository, :code_storage, :exploring, :ci, :other, :joining_team]).with_suffix } describe 'validations' do describe '#job_title' do diff --git a/spec/models/user_preference_spec.rb b/spec/models/user_preference_spec.rb index 5806f123871..d4491aacd9f 100644 --- a/spec/models/user_preference_spec.rb +++ b/spec/models/user_preference_spec.rb @@ -80,12 +80,6 @@ RSpec.describe UserPreference do end end - describe '#timezone' do - it 'returns server time as default' do - expect(user_preference.timezone).to eq(Time.zone.tzinfo.name) - end - end - describe '#tab_width' do it 'is set to 8 by default' do # Intentionally not using factory here to test the constructor. diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index ca4c38d4663..db805a804c8 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -79,6 +79,9 @@ RSpec.describe User do it { is_expected.to delegate_method(:bio).to(:user_detail).allow_nil } it { is_expected.to delegate_method(:bio=).to(:user_detail).with_arguments(:args).allow_nil } + + it { is_expected.to delegate_method(:registration_objective).to(:user_detail).allow_nil } + it { is_expected.to delegate_method(:registration_objective=).to(:user_detail).with_arguments(:args).allow_nil } end describe 'associations' do @@ -123,7 +126,7 @@ RSpec.describe User do it { is_expected.to have_many(:callouts).class_name('UserCallout') } it { is_expected.to have_many(:group_callouts).class_name('Users::GroupCallout') } - describe "#user_detail" do + describe '#user_detail' do it 'does not persist `user_detail` by default' do expect(create(:user).user_detail).not_to be_persisted end @@ -160,25 +163,25 @@ RSpec.describe User do end end - describe "#abuse_report" do + describe '#abuse_report' do let(:current_user) { create(:user) } let(:other_user) { create(:user) } it { is_expected.to have_one(:abuse_report) } - it "refers to the abuse report whose user_id is the current user" do + it 'refers to the abuse report whose user_id is the current user' do abuse_report = create(:abuse_report, reporter: other_user, user: current_user) expect(current_user.abuse_report).to eq(abuse_report) end - it "does not refer to the abuse report whose reporter_id is the current user" do + it 'does not refer to the abuse report whose reporter_id is the current user' do create(:abuse_report, reporter: current_user, user: other_user) expect(current_user.abuse_report).to be_nil end - it "does not update the user_id of an abuse report when the user is updated" do + it 'does not update the user_id of an abuse report when the user is updated' do abuse_report = create(:abuse_report, reporter: current_user, user: other_user) current_user.block @@ -343,8 +346,9 @@ RSpec.describe User do it 'falls back to english when I18n.default_locale is not an available language' do I18n.default_locale = :kl + default_preferred_language = user.send(:default_preferred_language) - expect(user.preferred_language).to eq 'en' + expect(user.preferred_language).to eq default_preferred_language end end end @@ -374,7 +378,7 @@ RSpec.describe User do end context 'when username is changed' do - let(:user) { build_stubbed(:user, username: 'old_path', namespace: build_stubbed(:namespace)) } + let(:user) { build_stubbed(:user, username: 'old_path', namespace: build_stubbed(:user_namespace)) } it 'validates move_dir is allowed for the namespace' do expect(user.namespace).to receive(:any_project_has_container_registry_tags?).and_return(true) @@ -401,7 +405,7 @@ RSpec.describe User do user = build(:user, username: "test.#{type}") expect(user).not_to be_valid - expect(user.errors.full_messages).to include('Username ending with a file extension is not allowed.') + expect(user.errors.full_messages).to include('Username ending with a reserved file extension is not allowed.') expect(build(:user, username: "test#{type}")).to be_valid end end @@ -490,6 +494,8 @@ RSpec.describe User do end describe 'email' do + let(:expected_error) { _('is not allowed for sign-up. Check with your administrator.') } + context 'when no signup domains allowed' do before do stub_application_setting(domain_allowlist: []) @@ -533,7 +539,7 @@ RSpec.describe User do it 'rejects example@test.com' do user = build(:user, email: "example@test.com") expect(user).to be_invalid - expect(user.errors.messages[:email].first).to eq(_('domain is not authorized for sign-up.')) + expect(user.errors.messages[:email].first).to eq(expected_error) end end @@ -550,13 +556,13 @@ RSpec.describe User do it 'rejects info@test.example.com' do user = build(:user, email: "info@test.example.com") expect(user).to be_invalid - expect(user.errors.messages[:email].first).to eq(_('domain is not authorized for sign-up.')) + expect(user.errors.messages[:email].first).to eq(expected_error) end it 'rejects example@test.com' do user = build(:user, email: "example@test.com") expect(user).to be_invalid - expect(user.errors.messages[:email].first).to eq(_('domain is not authorized for sign-up.')) + expect(user.errors.messages[:email].first).to eq(expected_error) end it 'accepts example@test.com when added by another user' do @@ -594,7 +600,7 @@ RSpec.describe User do it 'rejects info@example.com' do user = build(:user, email: 'info@example.com') expect(user).not_to be_valid - expect(user.errors.messages[:email].first).to eq(_('is not from an allowed domain.')) + expect(user.errors.messages[:email].first).to eq(expected_error) end it 'accepts info@example.com when added by another user' do @@ -628,7 +634,7 @@ RSpec.describe User do it 'rejects info@example.com' do user = build(:user, email: 'info@example.com') expect(user).not_to be_valid - expect(user.errors.messages[:email].first).to eq(_('domain is not authorized for sign-up.')) + expect(user.errors.messages[:email].first).to eq(expected_error) end end end @@ -669,7 +675,7 @@ RSpec.describe User do user = build(:user, email: 'info@gitlab.com') expect(user).not_to be_valid - expect(user.errors.messages[:email].first).to eq(_('is not allowed. Try again with a different email address, or contact your GitLab admin.')) + expect(user.errors.messages[:email].first).to eq(expected_error) end it 'does accept a valid email address' do @@ -715,7 +721,7 @@ RSpec.describe User do end end - describe "scopes" do + describe 'scopes' do context 'blocked users' do let_it_be(:active_user) { create(:user) } let_it_be(:blocked_user) { create(:user, :blocked) } @@ -753,8 +759,8 @@ RSpec.describe User do end end - describe ".with_two_factor" do - it "returns users with 2fa enabled via OTP" do + describe '.with_two_factor' do + it 'returns users with 2fa enabled via OTP' do user_with_2fa = create(:user, :two_factor_via_otp) user_without_2fa = create(:user) users_with_two_factor = described_class.with_two_factor.pluck(:id) @@ -763,8 +769,8 @@ RSpec.describe User do expect(users_with_two_factor).not_to include(user_without_2fa.id) end - shared_examples "returns the right users" do |trait| - it "returns users with 2fa enabled via hardware token" do + shared_examples 'returns the right users' do |trait| + it 'returns users with 2fa enabled via hardware token' do user_with_2fa = create(:user, trait) user_without_2fa = create(:user) users_with_two_factor = described_class.with_two_factor.pluck(:id) @@ -773,7 +779,7 @@ RSpec.describe User do expect(users_with_two_factor).not_to include(user_without_2fa.id) end - it "returns users with 2fa enabled via OTP and hardware token" do + it 'returns users with 2fa enabled via OTP and hardware token' do user_with_2fa = create(:user, :two_factor_via_otp, trait) user_without_2fa = create(:user) users_with_two_factor = described_class.with_two_factor.pluck(:id) @@ -791,17 +797,17 @@ RSpec.describe User do end end - describe "and U2F" do + describe 'and U2F' do it_behaves_like "returns the right users", :two_factor_via_u2f end - describe "and WebAuthn" do + describe 'and WebAuthn' do it_behaves_like "returns the right users", :two_factor_via_webauthn end end - describe ".without_two_factor" do - it "excludes users with 2fa enabled via OTP" do + describe '.without_two_factor' do + it 'excludes users with 2fa enabled via OTP' do user_with_2fa = create(:user, :two_factor_via_otp) user_without_2fa = create(:user) users_without_two_factor = described_class.without_two_factor.pluck(:id) @@ -810,8 +816,8 @@ RSpec.describe User do expect(users_without_two_factor).not_to include(user_with_2fa.id) end - describe "and u2f" do - it "excludes users with 2fa enabled via U2F" do + describe 'and u2f' do + it 'excludes users with 2fa enabled via U2F' do user_with_2fa = create(:user, :two_factor_via_u2f) user_without_2fa = create(:user) users_without_two_factor = described_class.without_two_factor.pluck(:id) @@ -820,7 +826,7 @@ RSpec.describe User do expect(users_without_two_factor).not_to include(user_with_2fa.id) end - it "excludes users with 2fa enabled via OTP and U2F" do + it 'excludes users with 2fa enabled via OTP and U2F' do user_with_2fa = create(:user, :two_factor_via_otp, :two_factor_via_u2f) user_without_2fa = create(:user) users_without_two_factor = described_class.without_two_factor.pluck(:id) @@ -830,8 +836,8 @@ RSpec.describe User do end end - describe "and webauthn" do - it "excludes users with 2fa enabled via WebAuthn" do + describe 'and webauthn' do + it 'excludes users with 2fa enabled via WebAuthn' do user_with_2fa = create(:user, :two_factor_via_webauthn) user_without_2fa = create(:user) users_without_two_factor = described_class.without_two_factor.pluck(:id) @@ -840,7 +846,7 @@ RSpec.describe User do expect(users_without_two_factor).not_to include(user_with_2fa.id) end - it "excludes users with 2fa enabled via OTP and WebAuthn" do + it 'excludes users with 2fa enabled via OTP and WebAuthn' do user_with_2fa = create(:user, :two_factor_via_otp, :two_factor_via_webauthn) user_without_2fa = create(:user) users_without_two_factor = described_class.without_two_factor.pluck(:id) @@ -1073,7 +1079,7 @@ RSpec.describe User do end end - describe "Respond to" do + describe 'Respond to' do it { is_expected.to respond_to(:admin?) } it { is_expected.to respond_to(:name) } it { is_expected.to respond_to(:external?) } @@ -1095,7 +1101,7 @@ RSpec.describe User do let(:user) { create(:user) } let(:external_user) { create(:user, external: true) } - it "sets other properties as well" 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 @@ -1514,7 +1520,7 @@ RSpec.describe User do end describe '#generate_password' do - it "does not generate password by default" do + it 'does not generate password by default' do user = create(:user, password: 'abcdefghe') expect(user.password).to eq('abcdefghe') @@ -1882,14 +1888,14 @@ RSpec.describe User do describe 'deactivating a user' do let(:user) { create(:user, name: 'John Smith') } - context "an active user" do - it "can be deactivated" do + context 'an active user' do + it 'can be deactivated' do user.deactivate expect(user.deactivated?).to be_truthy end - context "when user deactivation emails are disabled" do + context 'when user deactivation emails are disabled' do before do stub_application_setting(user_deactivation_emails_enabled: false) end @@ -1900,7 +1906,7 @@ RSpec.describe User do end end - context "when user deactivation emails are enabled" do + context 'when user deactivation emails are enabled' do it 'sends deactivated user an email' do expect_next_instance_of(NotificationService) do |notification| allow(notification).to receive(:user_deactivated).with(user.name, user.notification_email_or_default) @@ -1911,12 +1917,12 @@ RSpec.describe User do end end - context "a user who is blocked" do + context 'a user who is blocked' do before do user.block end - it "cannot be deactivated" do + it 'cannot be deactivated' do user.deactivate expect(user.reload.deactivated?).to be_falsy @@ -2083,7 +2089,7 @@ RSpec.describe User do describe 'with defaults' do let(:user) { described_class.new } - it "applies defaults to user" do + it 'applies defaults to user' do expect(user.projects_limit).to eq(Gitlab.config.gitlab.default_projects_limit) expect(user.can_create_group).to eq(Gitlab.config.gitlab.default_can_create_group) expect(user.theme_id).to eq(Gitlab.config.gitlab.default_theme) @@ -2095,7 +2101,7 @@ RSpec.describe User do describe 'with default overrides' do let(:user) { described_class.new(projects_limit: 123, can_create_group: false, can_create_team: true) } - it "applies defaults to user" do + it 'applies defaults to user' do expect(user.projects_limit).to eq(123) expect(user.can_create_group).to be_falsey expect(user.theme_id).to eq(1) @@ -2114,7 +2120,7 @@ RSpec.describe User do stub_application_setting(user_default_external: true) end - it "creates external user by default" do + it 'creates external user by default' do user = create(:user) expect(user.external).to be_truthy @@ -2123,7 +2129,7 @@ RSpec.describe User do end describe 'with default overrides' do - it "creates a non-external user" do + it 'creates a non-external user' do user = create(:user, external: false) expect(user.external).to be_falsey @@ -2139,7 +2145,7 @@ RSpec.describe User do } protocol_and_expectation.each do |protocol, expected| - it "has correct require_ssh_key?" do + it 'has correct require_ssh_key?' do stub_application_setting(enabled_git_access_protocol: protocol) user = build(:user) @@ -2542,71 +2548,79 @@ RSpec.describe User do end describe '.find_by_full_path' do - let!(:user) { create(:user) } + using RSpec::Parameterized::TableSyntax - context 'with a route matching the given path' do - let!(:route) { user.namespace.route } + # TODO: this `where/when` can be removed in issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070 + # At that point we only need to check `user_namespace` + where(namespace_type: [:namespace, :user_namespace]) - it 'returns the user' do - expect(described_class.find_by_full_path(route.path)).to eq(user) - end + with_them do + let!(:user) { create(:user, namespace: create(namespace_type)) } - it 'is case-insensitive' do - expect(described_class.find_by_full_path(route.path.upcase)).to eq(user) - expect(described_class.find_by_full_path(route.path.downcase)).to eq(user) - end - end + context 'with a route matching the given path' do + let!(:route) { user.namespace.route } - context 'with a redirect route matching the given path' do - let!(:redirect_route) { user.namespace.redirect_routes.create!(path: 'foo') } + it 'returns the user' do + expect(described_class.find_by_full_path(route.path)).to eq(user) + end - context 'without the follow_redirects option' do - it 'returns nil' do - expect(described_class.find_by_full_path(redirect_route.path)).to eq(nil) + it 'is case-insensitive' do + expect(described_class.find_by_full_path(route.path.upcase)).to eq(user) + expect(described_class.find_by_full_path(route.path.downcase)).to eq(user) end end - context 'with the follow_redirects option set to true' do - it 'returns the user' do - expect(described_class.find_by_full_path(redirect_route.path, follow_redirects: true)).to eq(user) + context 'with a redirect route matching the given path' do + let!(:redirect_route) { user.namespace.redirect_routes.create!(path: 'foo') } + + context 'without the follow_redirects option' do + it 'returns nil' do + expect(described_class.find_by_full_path(redirect_route.path)).to eq(nil) + end end - it 'is case-insensitive' do - expect(described_class.find_by_full_path(redirect_route.path.upcase, follow_redirects: true)).to eq(user) - expect(described_class.find_by_full_path(redirect_route.path.downcase, follow_redirects: true)).to eq(user) + context 'with the follow_redirects option set to true' do + it 'returns the user' do + expect(described_class.find_by_full_path(redirect_route.path, follow_redirects: true)).to eq(user) + end + + it 'is case-insensitive' do + expect(described_class.find_by_full_path(redirect_route.path.upcase, follow_redirects: true)).to eq(user) + expect(described_class.find_by_full_path(redirect_route.path.downcase, follow_redirects: true)).to eq(user) + end end end - end - context 'without a route or a redirect route matching the given path' do - context 'without the follow_redirects option' do - it 'returns nil' do - expect(described_class.find_by_full_path('unknown')).to eq(nil) + context 'without a route or a redirect route matching the given path' do + context 'without the follow_redirects option' do + it 'returns nil' do + expect(described_class.find_by_full_path('unknown')).to eq(nil) + end end - end - context 'with the follow_redirects option set to true' do - it 'returns nil' do - expect(described_class.find_by_full_path('unknown', follow_redirects: true)).to eq(nil) + context 'with the follow_redirects option set to true' do + it 'returns nil' do + expect(described_class.find_by_full_path('unknown', follow_redirects: true)).to eq(nil) + end end end - end - context 'with a group route matching the given path' do - let!(:group) { create(:group, path: 'group_path') } + context 'with a group route matching the given path' do + let!(:group) { create(:group, path: 'group_path') } - context 'when the group namespace has an owner_id (legacy data)' do - before do - group.update!(owner_id: user.id) - end + context 'when the group namespace has an owner_id (legacy data)' do + before do + group.update!(owner_id: user.id) + end - it 'returns nil' do - expect(described_class.find_by_full_path('group_path')).to eq(nil) + it 'returns nil' do + expect(described_class.find_by_full_path('group_path')).to eq(nil) + end end - end - context 'when the group namespace does not have an owner_id' do - it 'returns nil' do - expect(described_class.find_by_full_path('group_path')).to eq(nil) + context 'when the group namespace does not have an owner_id' do + it 'returns nil' do + expect(described_class.find_by_full_path('group_path')).to eq(nil) + end end end end @@ -2615,7 +2629,7 @@ RSpec.describe User do describe 'all_ssh_keys' do it { is_expected.to have_many(:keys).dependent(:destroy) } - it "has all ssh keys" do + it 'has all ssh keys' do user = create :user key = create :key, key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD33bWLBxu48Sev9Fert1yzEO4WGcWglWF7K/AwblIUFselOt/QdOL9DSjpQGxLagO1s9wl53STIO8qGS4Ms0EJZyIXOEFMjFJ5xmjSy+S37By4sG7SsltQEHMxtbtFOaW5LV2wCrX+rUsRNqLMamZjgjcPO0/EgGCXIGMAYW4O7cwGZdXWYIhQ1Vwy+CsVMDdPkPgBXqK7nR/ey8KMs8ho5fMNgB5hBw/AL9fNGhRw3QTD6Q12Nkhl4VZES2EsZqlpNnJttnPdp847DUsT6yuLRlfiQfz5Cn9ysHFdXObMN5VYIiPFwHeYCZp1X2S4fDZooRE8uOLTfxWHPXwrhqSH", user_id: user.id @@ -2651,10 +2665,10 @@ RSpec.describe User do end end - describe "#clear_avatar_caches" do + describe '#clear_avatar_caches' do let(:user) { create(:user) } - it "clears the avatar cache when saving" do + 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) @@ -3180,7 +3194,7 @@ RSpec.describe User do end end - describe "#last_active_at" do + describe '#last_active_at' do let(:last_activity_on) { 5.days.ago.to_date } let(:current_sign_in_at) { 8.days.ago } @@ -3218,7 +3232,7 @@ RSpec.describe User do end end - describe "#can_be_deactivated?" do + describe '#can_be_deactivated?' do let(:activity) { {} } let(:user) { create(:user, name: 'John Smith', **activity) } let(:day_within_minium_inactive_days_threshold) { User::MINIMUM_INACTIVE_DAYS.pred.days.ago } @@ -3236,7 +3250,7 @@ RSpec.describe User do end end - context "a user who is not active" do + context 'a user who is not active' do before do user.block end @@ -3277,7 +3291,7 @@ RSpec.describe User do end end - describe "#contributed_projects" do + describe '#contributed_projects' do subject { create(:user) } let!(:project1) { create(:project) } @@ -3292,11 +3306,11 @@ RSpec.describe User do project2.add_maintainer(subject) end - it "includes IDs for projects the user has pushed to" do + it 'includes IDs for projects the user has pushed to' do expect(subject.contributed_projects).to include(project1) end - it "includes IDs for projects the user has had merge requests merged into" do + it 'includes IDs for projects the user has had merge requests merged into' do expect(subject.contributed_projects).to include(project3) end @@ -3390,7 +3404,7 @@ RSpec.describe User do end end - describe "#recent_push" do + describe '#recent_push' do let(:user) { build(:user) } let(:project) { build(:project) } let(:event) { build(:push_event) } @@ -3554,7 +3568,7 @@ RSpec.describe User do expect(user.authorized_projects).to include(project) end - it "includes personal projects user has been given access to" do + it 'includes personal projects user has been given access to' do user1 = create(:user) user2 = create(:user) project = create(:project, :private, namespace: user1.namespace) @@ -3564,7 +3578,7 @@ RSpec.describe User do expect(user2.authorized_projects).to include(project) end - it "includes projects of groups user has been added to" do + it 'includes projects of groups user has been added to' do group = create(:group) project = create(:project, group: group) user = create(:user) @@ -3574,7 +3588,7 @@ RSpec.describe User do expect(user.authorized_projects).to include(project) end - it "does not include projects of groups user has been removed from" do + it 'does not include projects of groups user has been removed from', :sidekiq_inline do group = create(:group) project = create(:project, group: group) user = create(:user) @@ -3599,7 +3613,7 @@ RSpec.describe User do expect(user.authorized_projects).to include(project) end - it "does not include destroyed projects user had access to" do + it 'does not include destroyed projects user had access to' do user1 = create(:user) user2 = create(:user) project = create(:project, :private, namespace: user1.namespace) @@ -3613,7 +3627,7 @@ RSpec.describe User do expect(user2.authorized_projects).not_to include(project) end - it "does not include projects of destroyed groups user had access to" do + it 'does not include projects of destroyed groups user had access to' do group = create(:group) project = create(:project, namespace: group) user = create(:user) @@ -3841,7 +3855,7 @@ RSpec.describe User do end context 'with runner in a personal project' do - let!(:namespace) { create(:namespace, owner: user) } + let!(:namespace) { create(:user_namespace, owner: user) } let!(:project) { create(:project, namespace: namespace) } let!(:runner) { create(:ci_runner, :project, projects: [project]) } @@ -3909,7 +3923,7 @@ RSpec.describe User do end context 'with personal project runner in an owned group in an owned namespace and a group runner in that group' do - let!(:namespace) { create(:namespace, owner: user) } + let!(:namespace) { create(:user_namespace, owner: user) } let!(:group) { create(:group) } let!(:group_runner) { create(:ci_runner, :group, groups: [group]) } let!(:project) { create(:project, namespace: namespace, group: group) } @@ -3923,7 +3937,7 @@ RSpec.describe User do end context 'with personal project runner in an owned namespace, an owned group, a subgroup and a group runner in that subgroup' do - let!(:namespace) { create(:namespace, owner: user) } + let!(:namespace) { create(:user_namespace, owner: user) } let!(:group) { create(:group) } let!(:subgroup) { create(:group, parent: group) } let!(:group_runner) { create(:ci_runner, :group, groups: [subgroup]) } @@ -4166,7 +4180,7 @@ RSpec.describe User do expect(user.admin).to be true end - it "accepts string values in addition to symbols" do + it 'accepts string values in addition to symbols' do user.access_level = 'admin' expect(user.access_level).to eq(:admin) @@ -4247,7 +4261,7 @@ RSpec.describe User do expect(ghost.user_type).to eq 'ghost' end - it "does not create a second ghost user if one is already present" do + it 'does not create a second ghost user if one is already present' do expect do described_class.ghost described_class.ghost @@ -4256,7 +4270,7 @@ RSpec.describe User do end context "when a regular user exists with the username 'ghost'" do - it "creates a ghost user with a non-conflicting username" do + it 'creates a ghost user with a non-conflicting username' do create(:user, username: 'ghost') ghost = described_class.ghost @@ -4266,7 +4280,7 @@ RSpec.describe User do end context "when a regular user exists with the email 'ghost@example.com'" do - it "creates a ghost user with a non-conflicting email" do + it 'creates a ghost user with a non-conflicting email' do create(:user, email: 'ghost@example.com') ghost = described_class.ghost @@ -4605,6 +4619,7 @@ RSpec.describe User do user.save! expect(user.namespace).not_to be_nil + expect(user.namespace).to be_kind_of(Namespaces::UserNamespace) end it 'creates the namespace setting' do @@ -4746,7 +4761,7 @@ RSpec.describe User do it { is_expected.to be true } end - context 'when email and username aren\'t changed' do + context "when email and username aren't changed" do before do user.name = 'new_name' end @@ -5057,26 +5072,26 @@ RSpec.describe User do subject { user.required_terms_not_accepted? } - context "when terms are not enforced" do + context 'when terms are not enforced' do it { is_expected.to be_falsey } end - context "when terms are enforced" do + context 'when terms are enforced' do before do enforce_terms end - it "is not accepted by the user" do + it 'is not accepted by the user' do expect(subject).to be_truthy end - it "is accepted by the user" do + it 'is accepted by the user' do accept_terms(user) expect(subject).to be_falsey end - it "auto accepts the term for project bots" do + it 'auto accepts the term for project bots' do expect(project_bot.required_terms_not_accepted?).to be_falsey end end @@ -6165,4 +6180,14 @@ RSpec.describe User do it_behaves_like 'groups_with_developer_maintainer_project_access examples' end end + + describe '.get_ids_by_username' do + let(:user_name) { 'user_name' } + let!(:user) { create(:user, username: user_name) } + let(:user_id) { user.id } + + it 'returns the id of each record matching username' do + expect(described_class.get_ids_by_username([user_name])).to match_array([user_id]) + end + end end diff --git a/spec/models/users/credit_card_validation_spec.rb b/spec/models/users/credit_card_validation_spec.rb index fb9f6e35038..d2b4f5ebd65 100644 --- a/spec/models/users/credit_card_validation_spec.rb +++ b/spec/models/users/credit_card_validation_spec.rb @@ -4,4 +4,22 @@ require 'spec_helper' RSpec.describe Users::CreditCardValidation do it { is_expected.to belong_to(:user) } + + it { is_expected.to validate_length_of(:holder_name).is_at_most(26) } + it { is_expected.to validate_numericality_of(:last_digits).is_less_than_or_equal_to(9999) } + + describe '.similar_records' do + let(:card_details) { subject.attributes.slice(:expiration_date, :last_digits, :holder_name) } + + subject(:credit_card_validation) { create(:credit_card_validation) } + + let!(:match1) { create(:credit_card_validation, card_details) } + let!(:other1) { create(:credit_card_validation, card_details.merge(last_digits: 9)) } + let!(:match2) { create(:credit_card_validation, card_details) } + let!(:other2) { create(:credit_card_validation, card_details.merge(holder_name: 'foo bar')) } + + it 'returns records with matching credit card, ordered by credit_card_validated_at' do + expect(subject.similar_records).to eq([match2, match1, subject]) + end + end end diff --git a/spec/policies/clusters/agent_policy_spec.rb b/spec/policies/clusters/agent_policy_spec.rb new file mode 100644 index 00000000000..307d751b78b --- /dev/null +++ b/spec/policies/clusters/agent_policy_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::AgentPolicy do + let(:cluster_agent) { create(:cluster_agent, name: 'agent' )} + let(:user) { create(:admin) } + let(:policy) { described_class.new(user, cluster_agent) } + let(:project) { cluster_agent.project } + + describe 'rules' do + context 'when developer' do + before do + project.add_developer(user) + end + + it { expect(policy).to be_disallowed :admin_cluster } + end + + context 'when maintainer' do + before do + project.add_maintainer(user) + end + + it { expect(policy).to be_allowed :admin_cluster } + end + end +end diff --git a/spec/policies/clusters/agent_token_policy_spec.rb b/spec/policies/clusters/agent_token_policy_spec.rb new file mode 100644 index 00000000000..9ae99e66f59 --- /dev/null +++ b/spec/policies/clusters/agent_token_policy_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::AgentTokenPolicy do + let_it_be(:token) { create(:cluster_agent_token) } + + let(:user) { create(:user) } + let(:policy) { described_class.new(user, token) } + let(:project) { token.agent.project } + + describe 'rules' do + context 'when developer' do + before do + project.add_developer(user) + end + + it { expect(policy).to be_disallowed :admin_cluster } + it { expect(policy).to be_disallowed :read_cluster } + end + + context 'when maintainer' do + before do + project.add_maintainer(user) + end + + it { expect(policy).to be_allowed :admin_cluster } + it { expect(policy).to be_allowed :read_cluster } + end + end +end diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb index 482e12c029d..201ccf0fc14 100644 --- a/spec/policies/group_policy_spec.rb +++ b/spec/policies/group_policy_spec.rb @@ -1005,7 +1005,7 @@ RSpec.describe GroupPolicy do context 'with maintainer' do let(:current_user) { maintainer } - it { is_expected.to be_allowed(:update_runners_registration_token) } + it { is_expected.to be_disallowed(:update_runners_registration_token) } end context 'with reporter' do diff --git a/spec/policies/namespaces/project_namespace_policy_spec.rb b/spec/policies/namespaces/project_namespace_policy_spec.rb new file mode 100644 index 00000000000..22f3ccec1f8 --- /dev/null +++ b/spec/policies/namespaces/project_namespace_policy_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe NamespacePolicy do + let_it_be(:parent) { create(:namespace) } + let_it_be(:namespace) { create(:project_namespace, parent: parent) } + + let(:permissions) do + [:owner_access, :create_projects, :admin_namespace, :read_namespace, + :read_statistics, :transfer_projects, :create_package_settings, + :read_package_settings, :create_jira_connect_subscription] + end + + subject { described_class.new(current_user, namespace) } + + context 'with no user' do + let_it_be(:current_user) { nil } + + it { is_expected.to be_disallowed(*permissions) } + end + + context 'regular user' do + let_it_be(:current_user) { create(:user) } + + it { is_expected.to be_disallowed(*permissions) } + end + + context 'parent owner' do + let_it_be(:current_user) { parent.owner } + + it { is_expected.to be_disallowed(*permissions) } + end + + context 'admin' do + let_it_be(:current_user) { create(:admin) } + + context 'when admin mode is enabled', :enable_admin_mode do + it { is_expected.to be_allowed(*permissions) } + end + + context 'when admin mode is disabled' do + it { is_expected.to be_disallowed(*permissions) } + end + end +end diff --git a/spec/policies/namespace_policy_spec.rb b/spec/policies/namespaces/user_namespace_policy_spec.rb index b9823273de8..02eda31bfa7 100644 --- a/spec/policies/namespace_policy_spec.rb +++ b/spec/policies/namespaces/user_namespace_policy_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe NamespacePolicy do +RSpec.describe Namespaces::UserNamespacePolicy do let(:user) { create(:user) } let(:owner) { create(:user) } let(:admin) { create(:admin) } diff --git a/spec/presenters/clusters/cluster_presenter_spec.rb b/spec/presenters/clusters/cluster_presenter_spec.rb index c8da8a54f16..49126ed8e5f 100644 --- a/spec/presenters/clusters/cluster_presenter_spec.rb +++ b/spec/presenters/clusters/cluster_presenter_spec.rb @@ -30,129 +30,6 @@ RSpec.describe Clusters::ClusterPresenter do end end - describe '#item_link' do - let(:clusterable_presenter) { double('ClusterablePresenter', subject: clusterable) } - - subject { presenter.item_link(clusterable_presenter) } - - context 'for a group cluster' do - let(:cluster) { create(:cluster, cluster_type: :group_type, groups: [group]) } - let(:group) { create(:group, name: 'Foo') } - let(:cluster_link) { "<a href=\"#{group_cluster_path(cluster.group, cluster)}\">#{cluster.name}</a>" } - - before do - group.add_maintainer(user) - end - - shared_examples 'ancestor clusters' do - context 'ancestor clusters' do - let(:root_group) { create(:group, name: 'Root Group') } - let(:parent) { create(:group, name: 'parent', parent: root_group) } - let(:child) { create(:group, name: 'child', parent: parent) } - let(:group) { create(:group, name: 'group', parent: child) } - - before do - root_group.add_maintainer(user) - end - - context 'top level group cluster' do - let(:cluster) { create(:cluster, cluster_type: :group_type, groups: [root_group]) } - - it 'returns full group names and link for cluster' do - expect(subject).to eq("Root Group / #{cluster_link}") - end - - it 'is html safe' do - expect(presenter).to receive(:sanitize).with('Root Group').and_call_original - - expect(subject).to be_html_safe - end - end - - context 'first level group cluster' do - let(:cluster) { create(:cluster, cluster_type: :group_type, groups: [parent]) } - - it 'returns full group names and link for cluster' do - expect(subject).to eq("Root Group / parent / #{cluster_link}") - end - - it 'is html safe' do - expect(presenter).to receive(:sanitize).with('Root Group / parent').and_call_original - - expect(subject).to be_html_safe - end - end - - context 'second level group cluster' do - let(:cluster) { create(:cluster, cluster_type: :group_type, groups: [child]) } - - let(:ellipsis_h) do - /.*ellipsis_h.*/ - end - - it 'returns clipped group names and link for cluster' do - expect(subject).to match("Root Group / #{ellipsis_h} / child / #{cluster_link}") - end - - it 'is html safe' do - expect(presenter).to receive(:sanitize).with('Root Group / parent / child').and_call_original - - expect(subject).to be_html_safe - end - end - end - end - - context 'for a project clusterable' do - let(:clusterable) { project } - let(:project) { create(:project, group: group) } - - it 'returns the group name and the link for cluster' do - expect(subject).to eq("Foo / #{cluster_link}") - end - - it 'is html safe' do - expect(presenter).to receive(:sanitize).with('Foo').and_call_original - - expect(subject).to be_html_safe - end - - include_examples 'ancestor clusters' - end - - context 'for the group clusterable for the cluster' do - let(:clusterable) { group } - - it 'returns link for cluster' do - expect(subject).to eq(cluster_link) - end - - include_examples 'ancestor clusters' - - it 'is html safe' do - expect(subject).to be_html_safe - end - end - end - - context 'for a project cluster' do - let(:cluster) { create(:cluster, :project) } - let(:cluster_link) { "<a href=\"#{project_cluster_path(cluster.project, cluster)}\">#{cluster.name}</a>" } - - before do - cluster.project.add_maintainer(user) - end - - context 'for the project clusterable' do - let(:clusterable) { cluster.project } - - it 'returns link for cluster' do - expect(subject).to eq(cluster_link) - end - end - end - end - describe '#provider_label' do let(:cluster) { create(:cluster, provider_type: provider_type) } @@ -191,26 +68,6 @@ RSpec.describe Clusters::ClusterPresenter do end end - describe '#cluster_type_description' do - subject { described_class.new(cluster).cluster_type_description } - - context 'project_type cluster' do - it { is_expected.to eq('Project cluster') } - end - - context 'group_type cluster' do - let(:cluster) { create(:cluster, :provided_by_gcp, :group) } - - it { is_expected.to eq('Group cluster') } - end - - context 'instance_type cluster' do - let(:cluster) { create(:cluster, :provided_by_gcp, :instance) } - - it { is_expected.to eq('Instance cluster') } - end - end - describe '#show_path' do subject { described_class.new(cluster).show_path } diff --git a/spec/presenters/commit_status_presenter_spec.rb b/spec/presenters/commit_status_presenter_spec.rb index 4b2441d656e..f0bf1b860e4 100644 --- a/spec/presenters/commit_status_presenter_spec.rb +++ b/spec/presenters/commit_status_presenter_spec.rb @@ -15,6 +15,25 @@ RSpec.describe CommitStatusPresenter do expect(described_class.superclass).to eq(Gitlab::View::Presenter::Delegated) end + describe '#callout_failure_message' do + subject { presenter.callout_failure_message } + + context 'when troubleshooting doc is available' do + let(:failure_reason) { :environment_creation_failure } + + before do + build.failure_reason = failure_reason + end + + it 'appends the troubleshooting link' do + doc = described_class::TROUBLESHOOTING_DOC[failure_reason] + + expect(subject).to eq("#{described_class.callout_failure_messages[failure_reason]} " \ + "<a href=\"#{presenter.help_page_path(doc[:path], anchor: doc[:anchor])}\">How do I fix it?</a>") + end + end + end + describe 'covers all failure reasons' do let(:message) { presenter.callout_failure_message } diff --git a/spec/presenters/group_clusterable_presenter_spec.rb b/spec/presenters/group_clusterable_presenter_spec.rb index 84b97ba0bb7..b2dff2e3546 100644 --- a/spec/presenters/group_clusterable_presenter_spec.rb +++ b/spec/presenters/group_clusterable_presenter_spec.rb @@ -84,4 +84,10 @@ RSpec.describe GroupClusterablePresenter do it { is_expected.to eq(metrics_dashboard_group_cluster_path(group, cluster)) } end + + describe '#learn_more_link' do + subject { presenter.learn_more_link } + + it { is_expected.to include('user/group/clusters/index') } + end end diff --git a/spec/presenters/instance_clusterable_presenter_spec.rb b/spec/presenters/instance_clusterable_presenter_spec.rb index 6968e3a4da3..0ace57bbf4d 100644 --- a/spec/presenters/instance_clusterable_presenter_spec.rb +++ b/spec/presenters/instance_clusterable_presenter_spec.rb @@ -32,4 +32,10 @@ RSpec.describe InstanceClusterablePresenter do it { is_expected.to eq(metrics_dashboard_admin_cluster_path(cluster)) } end + + describe '#learn_more_link' do + subject { presenter.learn_more_link } + + it { is_expected.to include('user/instance/clusters/index') } + end end diff --git a/spec/presenters/project_clusterable_presenter_spec.rb b/spec/presenters/project_clusterable_presenter_spec.rb index 9057b518647..90b6671edd3 100644 --- a/spec/presenters/project_clusterable_presenter_spec.rb +++ b/spec/presenters/project_clusterable_presenter_spec.rb @@ -84,4 +84,10 @@ RSpec.describe ProjectClusterablePresenter do it { is_expected.to eq(metrics_dashboard_project_cluster_path(project, cluster)) } end + + describe '#learn_more_link' do + subject { presenter.learn_more_link } + + it { is_expected.to include('user/project/clusters/index') } + end end diff --git a/spec/requests/api/api_spec.rb b/spec/requests/api/api_spec.rb index 81620fce448..95eb503c6bc 100644 --- a/spec/requests/api/api_spec.rb +++ b/spec/requests/api/api_spec.rb @@ -100,39 +100,105 @@ RSpec.describe API::API do end end - context 'application context' do - let_it_be(:project) { create(:project) } + describe 'logging', :aggregate_failures do + let_it_be(:project) { create(:project, :public) } let_it_be(:user) { project.owner } - it 'logs all application context fields' do - allow_any_instance_of(Gitlab::GrapeLogging::Loggers::ContextLogger).to receive(:parameters) do - Gitlab::ApplicationContext.current.tap do |log_context| - expect(log_context).to match('correlation_id' => an_instance_of(String), - 'meta.caller_id' => 'GET /api/:version/projects/:id/issues', - 'meta.remote_ip' => an_instance_of(String), - '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') + context 'when the endpoint is handled by the application' do + context 'when the endpoint supports all possible fields' do + it 'logs all application context fields and the route' do + expect(described_class::LOG_FORMATTER).to receive(:call) do |_severity, _datetime, _, data| + expect(data.stringify_keys) + .to include('correlation_id' => an_instance_of(String), + 'meta.caller_id' => 'GET /api/:version/projects/:id/issues', + 'meta.remote_ip' => an_instance_of(String), + 'meta.project' => project.full_path, + 'meta.root_namespace' => project.namespace.full_path, + 'meta.user' => user.username, + 'meta.client_id' => a_string_matching(%r{\Auser/.+}), + 'meta.feature_category' => 'issue_tracking', + 'route' => '/api/:version/projects/:id/issues') + end + + get(api("/projects/#{project.id}/issues", user)) + + expect(response).to have_gitlab_http_status(:ok) end end - get(api("/projects/#{project.id}/issues", user)) + it 'skips context fields that do not apply' do + expect(described_class::LOG_FORMATTER).to receive(:call) do |_severity, _datetime, _, data| + expect(data.stringify_keys) + .to include('correlation_id' => an_instance_of(String), + 'meta.caller_id' => 'GET /api/:version/broadcast_messages', + 'meta.remote_ip' => an_instance_of(String), + 'meta.client_id' => a_string_matching(%r{\Aip/.+}), + 'meta.feature_category' => 'navigation', + 'route' => '/api/:version/broadcast_messages') + + expect(data.stringify_keys).not_to include('meta.project', 'meta.root_namespace', 'meta.user') + end + + get(api('/broadcast_messages')) + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when there is an unsupported media type' do + it 'logs the route and context metadata for the client' do + expect(described_class::LOG_FORMATTER).to receive(:call) do |_severity, _datetime, _, data| + expect(data.stringify_keys) + .to include('correlation_id' => an_instance_of(String), + 'meta.remote_ip' => an_instance_of(String), + 'meta.client_id' => a_string_matching(%r{\Aip/.+}), + 'route' => '/api/:version/users/:id') + + expect(data.stringify_keys).not_to include('meta.caller_id', 'meta.feature_category', 'meta.user') + end + + put(api("/users/#{user.id}", user), params: { 'name' => 'Test' }, headers: { 'Content-Type' => 'image/png' }) + + expect(response).to have_gitlab_http_status(:unsupported_media_type) + end end - it 'skips fields that do not apply' do - allow_any_instance_of(Gitlab::GrapeLogging::Loggers::ContextLogger).to receive(:parameters) do - Gitlab::ApplicationContext.current.tap do |log_context| - expect(log_context).to match('correlation_id' => an_instance_of(String), - 'meta.caller_id' => 'GET /api/:version/users', - 'meta.remote_ip' => an_instance_of(String), - 'meta.client_id' => an_instance_of(String), - 'meta.feature_category' => 'users') + context 'when there is an OPTIONS request' do + it 'logs the route and context metadata for the client' do + expect(described_class::LOG_FORMATTER).to receive(:call) do |_severity, _datetime, _, data| + expect(data.stringify_keys) + .to include('correlation_id' => an_instance_of(String), + 'meta.remote_ip' => an_instance_of(String), + 'meta.client_id' => a_string_matching(%r{\Auser/.+}), + 'meta.user' => user.username, + 'meta.feature_category' => 'users', + 'route' => '/api/:version/users') + + expect(data.stringify_keys).not_to include('meta.caller_id') end + + options(api('/users', user)) + + expect(response).to have_gitlab_http_status(:no_content) end + end - get(api('/users')) + context 'when the API version is not matched' do + it 'logs the route and context metadata for the client' do + expect(described_class::LOG_FORMATTER).to receive(:call) do |_severity, _datetime, _, data| + expect(data.stringify_keys) + .to include('correlation_id' => an_instance_of(String), + 'meta.remote_ip' => an_instance_of(String), + 'meta.client_id' => a_string_matching(%r{\Aip/.+}), + 'route' => '/api/:version/*path') + + expect(data.stringify_keys).not_to include('meta.caller_id', 'meta.user') + end + + get('/api/v4_or_is_it') + + expect(response).to have_gitlab_http_status(:not_found) + end end end diff --git a/spec/requests/api/bulk_imports_spec.rb b/spec/requests/api/bulk_imports_spec.rb index 1a28687c830..1602819a02e 100644 --- a/spec/requests/api/bulk_imports_spec.rb +++ b/spec/requests/api/bulk_imports_spec.rb @@ -21,6 +21,15 @@ RSpec.describe API::BulkImports do end describe 'POST /bulk_imports' do + before do + allow_next_instance_of(BulkImports::Clients::HTTP) do |instance| + allow(instance) + .to receive(:instance_version) + .and_return( + Gitlab::VersionInfo.new(::BulkImport::MIN_MAJOR_VERSION, ::BulkImport::MIN_MINOR_VERSION_FOR_PROJECT)) + end + end + it 'starts a new migration' do post api('/bulk_imports', user), params: { configuration: { diff --git a/spec/requests/api/ci/resource_groups_spec.rb b/spec/requests/api/ci/resource_groups_spec.rb new file mode 100644 index 00000000000..f5b68557a0d --- /dev/null +++ b/spec/requests/api/ci/resource_groups_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Ci::ResourceGroups do + let_it_be(:project) { create(:project) } + let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } } + let_it_be(:reporter) { create(:user).tap { |u| project.add_reporter(u) } } + + let(:user) { developer } + + describe 'GET /projects/:id/resource_groups/:key' do + subject { get api("/projects/#{project.id}/resource_groups/#{key}", user) } + + let!(:resource_group) { create(:ci_resource_group, project: project) } + let(:key) { resource_group.key } + + it 'returns a resource group', :aggregate_failures do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['id']).to eq(resource_group.id) + expect(json_response['key']).to eq(resource_group.key) + expect(json_response['process_mode']).to eq(resource_group.process_mode) + expect(Time.parse(json_response['created_at'])).to be_like_time(resource_group.created_at) + expect(Time.parse(json_response['updated_at'])).to be_like_time(resource_group.updated_at) + end + + context 'when user is reporter' do + let(:user) { reporter } + + it 'returns forbidden' do + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'when there is no corresponding resource group' do + let(:key) { 'unknown' } + + it 'returns not found' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + describe 'PUT /projects/:id/resource_groups/:key' do + subject { put api("/projects/#{project.id}/resource_groups/#{key}", user), params: params } + + let!(:resource_group) { create(:ci_resource_group, project: project) } + let(:key) { resource_group.key } + let(:params) { { process_mode: :oldest_first } } + + it 'changes the process mode of a resource group' do + expect { subject } + .to change { resource_group.reload.process_mode }.from('unordered').to('oldest_first') + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['process_mode']).to eq('oldest_first') + end + + context 'with invalid parameter' do + let(:params) { { process_mode: :unknown } } + + it 'returns bad request' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + + context 'when user is reporter' do + let(:user) { reporter } + + it 'returns forbidden' do + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'when there is no corresponding resource group' do + let(:key) { 'unknown' } + + it 'returns not found' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end +end 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 adac81ff6f4..c3fbef9be48 100644 --- a/spec/requests/api/ci/runner/jobs_request_post_spec.rb +++ b/spec/requests/api/ci/runner/jobs_request_post_spec.rb @@ -816,7 +816,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do subject { request_job(id: job.id) } - it_behaves_like 'storing arguments in the application context' do + it_behaves_like 'storing arguments in the application context for the API' do let(:expected_params) { { user: user.username, project: project.full_path, client_id: "user/#{user.id}" } } end @@ -827,7 +827,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do end context 'when the runner is of project type' do - it_behaves_like 'storing arguments in the application context' do + it_behaves_like 'storing arguments in the application context for the API' do let(:expected_params) { { project: project.full_path, client_id: "runner/#{runner.id}" } } end @@ -841,7 +841,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do let(:group) { create(:group) } let(:runner) { create(:ci_runner, :group, groups: [group]) } - it_behaves_like 'storing arguments in the application context' do + it_behaves_like 'storing arguments in the application context for the API' do let(:expected_params) { { root_namespace: group.full_path_components.first, client_id: "runner/#{runner.id}" } } end diff --git a/spec/requests/api/ci/runner/runners_delete_spec.rb b/spec/requests/api/ci/runner/runners_delete_spec.rb index 6c6c465f161..9d1bae7cce8 100644 --- a/spec/requests/api/ci/runner/runners_delete_spec.rb +++ b/spec/requests/api/ci/runner/runners_delete_spec.rb @@ -51,7 +51,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do let(:params) { { token: runner.token } } end - it_behaves_like 'storing arguments in the application context' do + it_behaves_like 'storing arguments in the application context for the API' do let(:expected_params) { { client_id: "runner/#{runner.id}" } } 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 17b988a60c5..b3a7d591c93 100644 --- a/spec/requests/api/ci/runner/runners_post_spec.rb +++ b/spec/requests/api/ci/runner/runners_post_spec.rb @@ -58,7 +58,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do expect(runner).to be_instance_type end - it_behaves_like 'storing arguments in the application context' do + it_behaves_like 'storing arguments in the application context for the API' do subject { request } let(:expected_params) { { client_id: "runner/#{::Ci::Runner.first.id}" } } @@ -84,7 +84,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do expect(runner).to be_project_type end - it_behaves_like 'storing arguments in the application context' do + it_behaves_like 'storing arguments in the application context for the API' do subject { request } let(:expected_params) { { project: project.full_path, client_id: "runner/#{::Ci::Runner.first.id}" } } @@ -190,7 +190,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do expect(runner).to be_group_type end - it_behaves_like 'storing arguments in the application context' do + it_behaves_like 'storing arguments in the application context for the API' do subject { request } let(:expected_params) { { root_namespace: group.full_path_components.first, client_id: "runner/#{::Ci::Runner.first.id}" } } 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 c2e97446738..4680076acae 100644 --- a/spec/requests/api/ci/runner/runners_verify_post_spec.rb +++ b/spec/requests/api/ci/runner/runners_verify_post_spec.rb @@ -45,7 +45,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do expect(response).to have_gitlab_http_status(:ok) end - it_behaves_like 'storing arguments in the application context' do + it_behaves_like 'storing arguments in the application context for the API' do let(:expected_params) { { client_id: "runner/#{runner.id}" } } end end diff --git a/spec/requests/api/ci/runners_reset_registration_token_spec.rb b/spec/requests/api/ci/runners_reset_registration_token_spec.rb index 7623d3f1b17..df64c0bd22b 100644 --- a/spec/requests/api/ci/runners_reset_registration_token_spec.rb +++ b/spec/requests/api/ci/runners_reset_registration_token_spec.rb @@ -118,7 +118,7 @@ RSpec.describe API::Ci::Runners do end include_context 'when authorized', 'group' do - let_it_be(:user) { create_default(:group_member, :maintainer, user: create(:user), group: group ).user } + let_it_be(:user) { create_default(:group_member, :owner, user: create(:user), group: group ).user } def get_token group.reload.runners_token diff --git a/spec/requests/api/ci/runners_spec.rb b/spec/requests/api/ci/runners_spec.rb index 902938d7d02..6879dfc9572 100644 --- a/spec/requests/api/ci/runners_spec.rb +++ b/spec/requests/api/ci/runners_spec.rb @@ -291,6 +291,16 @@ RSpec.describe API::Ci::Runners do end end + context 'when the runner is a group runner' do + it "returns the runner's details" do + get api("/runners/#{group_runner_a.id}", admin) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['description']).to eq(group_runner_a.description) + expect(json_response['groups'].first['id']).to eq(group.id) + end + end + context "runner project's administrative user" do context 'when runner is not shared' do it "returns runner's details" do @@ -600,6 +610,94 @@ RSpec.describe API::Ci::Runners do end end + describe 'POST /runners/:id/reset_authentication_token' do + context 'admin user' do + it 'resets shared runner authentication token' do + expect do + post api("/runners/#{shared_runner.id}/reset_authentication_token", admin) + + expect(response).to have_gitlab_http_status(:success) + expect(json_response).to eq({ 'token' => shared_runner.reload.token }) + end.to change { shared_runner.reload.token } + end + + it 'returns 404 if runner does not exist' do + post api('/runners/0/reset_authentication_token', admin) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'authorized user' do + it 'does not reset project runner authentication token without access to it' do + expect do + post api("/runners/#{project_runner.id}/reset_authentication_token", user2) + expect(response).to have_gitlab_http_status(:forbidden) + end.not_to change { project_runner.reload.token } + end + + it 'resets project runner authentication token for owned project' do + expect do + post api("/runners/#{project_runner.id}/reset_authentication_token", user) + + expect(response).to have_gitlab_http_status(:success) + expect(json_response).to eq({ 'token' => project_runner.reload.token }) + end.to change { project_runner.reload.token } + end + + it 'does not reset group runner authentication token with guest access' do + expect do + post api("/runners/#{group_runner_a.id}/reset_authentication_token", group_guest) + + expect(response).to have_gitlab_http_status(:forbidden) + end.not_to change { group_runner_a.reload.token } + end + + it 'does not reset group runner authentication token with reporter access' do + expect do + post api("/runners/#{group_runner_a.id}/reset_authentication_token", group_reporter) + + expect(response).to have_gitlab_http_status(:forbidden) + end.not_to change { group_runner_a.reload.token } + end + + it 'does not reset group runner authentication token with developer access' do + expect do + post api("/runners/#{group_runner_a.id}/reset_authentication_token", group_developer) + + expect(response).to have_gitlab_http_status(:forbidden) + end.not_to change { group_runner_a.reload.token } + end + + it 'does not reset group runner authentication token with maintainer access' do + expect do + post api("/runners/#{group_runner_a.id}/reset_authentication_token", group_maintainer) + + expect(response).to have_gitlab_http_status(:forbidden) + end.not_to change { group_runner_a.reload.token } + end + + it 'resets group runner authentication token with owner access' do + expect do + post api("/runners/#{group_runner_a.id}/reset_authentication_token", user) + + expect(response).to have_gitlab_http_status(:success) + expect(json_response).to eq({ 'token' => group_runner_a.reload.token }) + end.to change { group_runner_a.reload.token } + end + end + + context 'unauthorized user' do + it 'does not reset authentication token' do + expect do + post api("/runners/#{shared_runner.id}/reset_authentication_token") + + expect(response).to have_gitlab_http_status(:unauthorized) + end.not_to change { shared_runner.reload.token } + end + end + end + describe 'GET /runners/:id/jobs' do let_it_be(:job_1) { create(:ci_build) } let_it_be(:job_2) { create(:ci_build, :running, runner: shared_runner, project: project) } diff --git a/spec/requests/api/ci/triggers_spec.rb b/spec/requests/api/ci/triggers_spec.rb index 410e2ae405e..d270a16d28d 100644 --- a/spec/requests/api/ci/triggers_spec.rb +++ b/spec/requests/api/ci/triggers_spec.rb @@ -131,7 +131,7 @@ RSpec.describe API::Ci::Triggers do let(:subject_proc) { proc { post api("/projects/#{project.id}/ref/master/trigger/pipeline?token=#{trigger_token}"), params: { ref: 'refs/heads/other-branch' } } } context 'when triggering a pipeline from a trigger token' do - it_behaves_like 'storing arguments in the application context' + it_behaves_like 'storing arguments in the application context for the API' it_behaves_like 'not executing any extra queries for the application context' end @@ -142,7 +142,7 @@ RSpec.describe API::Ci::Triggers do context 'when other job is triggered by a user' do let(:trigger_token) { create(:ci_build, :running, project: project, user: user).token } - it_behaves_like 'storing arguments in the application context' + it_behaves_like 'storing arguments in the application context for the API' it_behaves_like 'not executing any extra queries for the application context' end @@ -151,7 +151,7 @@ RSpec.describe API::Ci::Triggers do let(:runner) { create(:ci_runner) } let(:expected_params) { { client_id: "runner/#{runner.id}", project: project.full_path } } - it_behaves_like 'storing arguments in the application context' + it_behaves_like 'storing arguments in the application context for the API' it_behaves_like 'not executing any extra queries for the application context', 1 end end diff --git a/spec/requests/api/container_repositories_spec.rb b/spec/requests/api/container_repositories_spec.rb index 8d7494ffce1..9809702467d 100644 --- a/spec/requests/api/container_repositories_spec.rb +++ b/spec/requests/api/container_repositories_spec.rb @@ -48,6 +48,19 @@ RSpec.describe API::ContainerRepositories do expect(response).to match_response_schema('registry/repository') end + context 'with a network error' do + before do + stub_container_registry_network_error(client_method: :repository_tags) + end + + it 'returns a matching schema' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('registry/repository') + end + end + context 'with tags param' do let(:url) { "/registry/repositories/#{repository.id}?tags=true" } @@ -61,6 +74,19 @@ RSpec.describe API::ContainerRepositories do expect(json_response['id']).to eq(repository.id) expect(response.body).to include('tags') end + + context 'with a network error' do + before do + stub_container_registry_network_error(client_method: :repository_tags) + end + + it 'returns a connection error message' do + subject + + expect(response).to have_gitlab_http_status(:service_unavailable) + expect(json_response['message']).to include('We are having trouble connecting to the Container Registry') + end + end end context 'with tags_count param' do diff --git a/spec/requests/api/deployments_spec.rb b/spec/requests/api/deployments_spec.rb index 38c96cd37af..69f7b54c277 100644 --- a/spec/requests/api/deployments_spec.rb +++ b/spec/requests/api/deployments_spec.rb @@ -376,6 +376,16 @@ RSpec.describe API::Deployments do expect(json_response['status']).to eq('success') end + it 'returns an error when an invalid status transition is detected' do + put( + api("/projects/#{project.id}/deployments/#{deploy.id}", user), + params: { status: 'running' } + ) + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']['status']).to include(%Q{cannot transition via \"run\"}) + end + it 'links merge requests when the deployment status changes to success', :sidekiq_inline do mr = create( :merge_request, diff --git a/spec/requests/api/environments_spec.rb b/spec/requests/api/environments_spec.rb index bc7bb7523c9..5fb24dc91a4 100644 --- a/spec/requests/api/environments_spec.rb +++ b/spec/requests/api/environments_spec.rb @@ -18,6 +18,7 @@ RSpec.describe API::Environments do get api("/projects/#{project.id}/environments", user) expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/environments') expect(response).to include_pagination_headers expect(json_response).to be_an Array expect(json_response.size).to eq(1) @@ -167,6 +168,7 @@ RSpec.describe API::Environments do post api("/projects/#{project.id}/environments", user), params: { name: "mepmep" } expect(response).to have_gitlab_http_status(:created) + expect(response).to match_response_schema('public_api/v4/environment') expect(json_response['name']).to eq('mepmep') expect(json_response['slug']).to eq('mepmep') expect(json_response['external']).to be nil @@ -212,6 +214,7 @@ RSpec.describe API::Environments do params: { name: 'Mepmep', external_url: url } expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/environment') expect(json_response['name']).to eq('Mepmep') expect(json_response['external_url']).to eq(url) end @@ -250,7 +253,7 @@ RSpec.describe API::Environments do expect(response).to have_gitlab_http_status(:forbidden) end - it 'returns a 200 for stopped environment' do + it 'returns a 204 for stopped environment' do environment.stop delete api("/projects/#{project.id}/environments/#{environment.id}", user) @@ -294,6 +297,7 @@ RSpec.describe API::Environments do it 'returns a 200' do expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/environment') end it 'actually stops the environment' do @@ -327,6 +331,7 @@ RSpec.describe API::Environments do expect(response).to have_gitlab_http_status(:ok) expect(response).to match_response_schema('public_api/v4/environment') + expect(json_response['last_deployment']).to be_present end end diff --git a/spec/requests/api/error_tracking_client_keys_spec.rb b/spec/requests/api/error_tracking/client_keys_spec.rb index 886ec5ade3d..00c1e8799e6 100644 --- a/spec/requests/api/error_tracking_client_keys_spec.rb +++ b/spec/requests/api/error_tracking/client_keys_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe API::ErrorTrackingClientKeys do +RSpec.describe API::ErrorTracking::ClientKeys do let_it_be(:guest) { create(:user) } let_it_be(:maintainer) { create(:user) } let_it_be(:setting) { create(:project_error_tracking_setting) } diff --git a/spec/requests/api/error_tracking_collector_spec.rb b/spec/requests/api/error_tracking/collector_spec.rb index 35d3ea01f87..7acadeb1287 100644 --- a/spec/requests/api/error_tracking_collector_spec.rb +++ b/spec/requests/api/error_tracking/collector_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe API::ErrorTrackingCollector do +RSpec.describe API::ErrorTracking::Collector do let_it_be(:project) { create(:project, :private) } let_it_be(:setting) { create(:project_error_tracking_setting, :integrated, project: project) } let_it_be(:client_key) { create(:error_tracking_client_key, project: project) } diff --git a/spec/requests/api/error_tracking_spec.rb b/spec/requests/api/error_tracking/project_settings_spec.rb index ec9a3378acc..161e4f01ea5 100644 --- a/spec/requests/api/error_tracking_spec.rb +++ b/spec/requests/api/error_tracking/project_settings_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe API::ErrorTracking do +RSpec.describe API::ErrorTracking::ProjectSettings do let_it_be(:user) { create(:user) } let(:setting) { create(:project_error_tracking_setting) } diff --git a/spec/requests/api/graphql/boards/board_list_issues_query_spec.rb b/spec/requests/api/graphql/boards/board_list_issues_query_spec.rb index 008241b8055..241c658441b 100644 --- a/spec/requests/api/graphql/boards/board_list_issues_query_spec.rb +++ b/spec/requests/api/graphql/boards/board_list_issues_query_spec.rb @@ -30,7 +30,7 @@ RSpec.describe 'get board lists' do nodes { lists { nodes { - issues(filters: {labelName: "#{label2.title}"}) { + issues(filters: {labelName: "#{label2.title}"}, first: 3) { count nodes { #{all_graphql_fields_for('issues'.classify)} @@ -44,6 +44,10 @@ RSpec.describe 'get board lists' do ) end + def issue_id + issues_data.map { |i| i['id'] } + end + def issue_titles issues_data.map { |i| i['title'] } end @@ -60,6 +64,7 @@ RSpec.describe 'get board lists' do let!(:issue3) { create(:issue, project: issue_project, labels: [label, label2], relative_position: nil) } let!(:issue4) { create(:issue, project: issue_project, labels: [label], relative_position: 9) } let!(:issue5) { create(:issue, project: issue_project, labels: [label2], relative_position: 432) } + let!(:issue6) { create(:issue, project: issue_project, labels: [label, label2], relative_position: nil) } context 'when the user does not have access to the board' do it 'returns nil' do @@ -72,14 +77,19 @@ RSpec.describe 'get board lists' do context 'when user can read the board' do before do board_parent.add_reporter(user) + post_graphql(query("id: \"#{global_id_of(label_list)}\""), current_user: user) end it 'can access the issues', :aggregate_failures do - post_graphql(query("id: \"#{global_id_of(label_list)}\""), current_user: user) - + # ties for relative positions are broken by id in ascending order by default expect(issue_titles).to eq([issue2.title, issue1.title, issue3.title]) expect(issue_relative_positions).not_to include(nil) end + + it 'does not set the relative positions of the issues not being returned', :aggregate_failures do + expect(issue_id).not_to include(issue6.id) + expect(issue3.relative_position).to be_nil + end end end diff --git a/spec/requests/api/graphql/boards/board_list_query_spec.rb b/spec/requests/api/graphql/boards/board_list_query_spec.rb new file mode 100644 index 00000000000..dec7ca715f2 --- /dev/null +++ b/spec/requests/api/graphql/boards/board_list_query_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Querying a Board list' do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:board) { create(:board, resource_parent: project) } + let_it_be(:label) { create(:label, project: project, name: 'foo') } + let_it_be(:list) { create(:list, board: board, label: label) } + let_it_be(:issue1) { create(:issue, project: project, labels: [label]) } + let_it_be(:issue2) { create(:issue, project: project, labels: [label], assignees: [current_user]) } + + let(:filters) { {} } + let(:query) do + graphql_query_for( + :board_list, + { id: list.to_global_id.to_s, issueFilters: filters }, + %w[title issuesCount] + ) + end + + subject { graphql_data['boardList'] } + + before do + post_graphql(query, current_user: current_user) + end + + context 'when the user has access to the list' do + before_all do + project.add_guest(current_user) + end + + it_behaves_like 'a working graphql query' + + it { is_expected.to include({ 'issuesCount' => 2, 'title' => list.title }) } + + context 'with matching issue filters' do + let(:filters) { { assigneeUsername: current_user.username } } + + it 'filters issues metadata' do + is_expected.to include({ 'issuesCount' => 1, 'title' => list.title }) + end + end + + context 'with unmatching issue filters' do + let(:filters) { { assigneeUsername: 'foo' } } + + it 'filters issues metadata' do + is_expected.to include({ 'issuesCount' => 0, 'title' => list.title }) + end + end + end + + context 'when the user does not have access to the list' do + it { is_expected.to be_nil } + end + + context 'when ID argument is missing' do + let(:query) do + graphql_query_for('boardList', {}, 'title') + end + + it 'raises an exception' do + expect(graphql_errors).to include(a_hash_including('message' => "Field 'boardList' is missing required arguments: id")) + end + end + + context 'when list ID is not found' do + let(:query) do + graphql_query_for('boardList', { id: "gid://gitlab/List/#{non_existing_record_id}" }, 'title') + end + + it { is_expected.to be_nil } + end + + it 'does not have an N+1 performance issue' do + a, b = create_list(:list, 2, board: board) + ctx = { current_user: current_user } + project.add_guest(current_user) + + baseline = graphql_query_for(:board_list, { id: global_id_of(a) }, 'title') + query = <<~GQL + query { + a: #{query_graphql_field(:board_list, { id: global_id_of(a) }, 'title')} + b: #{query_graphql_field(:board_list, { id: global_id_of(b) }, 'title')} + } + GQL + + control = ActiveRecord::QueryRecorder.new do + run_with_clean_state(baseline, context: ctx) + end + + expect { run_with_clean_state(query, context: ctx) }.not_to exceed_query_limit(control) + end +end diff --git a/spec/requests/api/graphql/boards/board_lists_query_spec.rb b/spec/requests/api/graphql/boards/board_lists_query_spec.rb index 2d52cddcacc..ace8c59e82d 100644 --- a/spec/requests/api/graphql/boards/board_lists_query_spec.rb +++ b/spec/requests/api/graphql/boards/board_lists_query_spec.rb @@ -92,9 +92,9 @@ RSpec.describe 'get board lists' do context 'when ascending' do it_behaves_like 'sorted paginated query' do - let(:sort_param) { } - let(:first_param) { 2 } - let(:expected_results) { lists.map { |list| global_id_of(list) } } + let(:sort_param) { } + let(:first_param) { 2 } + let(:all_records) { lists.map { |list| global_id_of(list) } } end end end diff --git a/spec/requests/api/graphql/ci/runner_spec.rb b/spec/requests/api/graphql/ci/runner_spec.rb index 74547196445..ab53ff654e9 100644 --- a/spec/requests/api/graphql/ci/runner_spec.rb +++ b/spec/requests/api/graphql/ci/runner_spec.rb @@ -6,6 +6,7 @@ RSpec.describe 'Query.runner(id)' do include GraphqlHelpers let_it_be(:user) { create(:user, :admin) } + let_it_be(:group) { create(:group) } let_it_be(:active_instance_runner) do create(:ci_runner, :instance, description: 'Runner 1', contacted_at: 2.hours.ago, @@ -18,12 +19,20 @@ RSpec.describe 'Query.runner(id)' do version: 'adfe157', revision: 'b', ip_address: '10.10.10.10', access_level: 1, run_untagged: true) end + let_it_be(:active_group_runner) do + create(:ci_runner, :group, groups: [group], description: 'Group runner 1', contacted_at: 2.hours.ago, + active: true, version: 'adfe156', revision: 'a', locked: true, ip_address: '127.0.0.1', maximum_timeout: 600, + access_level: 0, tag_list: %w[tag1 tag2], run_untagged: true) + end + def get_runner(id) case id when :active_instance_runner active_instance_runner when :inactive_instance_runner inactive_instance_runner + when :active_group_runner + active_group_runner end end @@ -61,7 +70,39 @@ RSpec.describe 'Query.runner(id)' do 'ipAddress' => runner.ip_address, 'runnerType' => runner.instance_type? ? 'INSTANCE_TYPE' : 'PROJECT_TYPE', 'jobCount' => 0, - 'projectCount' => nil + 'projectCount' => nil, + 'adminUrl' => "http://localhost/admin/runners/#{runner.id}", + 'userPermissions' => { + 'readRunner' => true, + 'updateRunner' => true, + 'deleteRunner' => true + } + ) + expect(runner_data['tagList']).to match_array runner.tag_list + end + end + + shared_examples 'retrieval with no admin url' do |runner_id| + let(:query) do + wrap_fields(query_graphql_path(query_path, all_graphql_fields_for('CiRunner'))) + end + + let(:query_path) do + [ + [:runner, { id: get_runner(runner_id).to_global_id.to_s }] + ] + end + + it 'retrieves expected fields' do + post_graphql(query, current_user: user) + + runner_data = graphql_data_at(:runner) + expect(runner_data).not_to be_nil + + runner = get_runner(runner_id) + expect(runner_data).to match a_hash_including( + 'id' => "gid://gitlab/Ci::Runner/#{runner.id}", + 'adminUrl' => nil ) expect(runner_data['tagList']).to match_array runner.tag_list end @@ -147,6 +188,39 @@ RSpec.describe 'Query.runner(id)' do it_behaves_like 'runner details fetch', :inactive_instance_runner end + describe 'for runner inside group request' do + let(:query) do + %( + query { + group(fullPath: "#{group.full_path}") { + runners { + edges { + webUrl + node { + id + } + } + } + } + } + ) + end + + it 'retrieves webUrl field with expected value' do + post_graphql(query, current_user: user) + + runner_data = graphql_data_at(:group, :runners, :edges) + expect(runner_data).to match_array [ + a_hash_including( + 'webUrl' => "http://localhost/groups/#{group.full_path}/-/runners/#{active_group_runner.id}", + 'node' => { + 'id' => "gid://gitlab/Ci::Runner/#{active_group_runner.id}" + } + ) + ] + end + end + describe 'for multiple runners' do let_it_be(:project1) { create(:project, :test_repo) } let_it_be(:project2) { create(:project, :test_repo) } @@ -176,7 +250,7 @@ RSpec.describe 'Query.runner(id)' do end before do - project_runner2.projects.clear + project_runner2.runner_projects.clear post_graphql(query, current_user: user) end @@ -205,6 +279,16 @@ RSpec.describe 'Query.runner(id)' do it_behaves_like 'retrieval by unauthorized user', :active_instance_runner end + describe 'by non-admin user' do + let(:user) { create(:user) } + + before do + group.add_user(user, Gitlab::Access::OWNER) + end + + it_behaves_like 'retrieval with no admin url', :active_group_runner + end + describe 'by unauthenticated user' do let(:user) { nil } diff --git a/spec/requests/api/graphql/ci/runners_spec.rb b/spec/requests/api/graphql/ci/runners_spec.rb index 778fe5b129e..51a07e60e15 100644 --- a/spec/requests/api/graphql/ci/runners_spec.rb +++ b/spec/requests/api/graphql/ci/runners_spec.rb @@ -95,9 +95,9 @@ RSpec.describe 'Query.runners' do let(:ordered_runners) { runners.sort_by(&:contacted_at) } it_behaves_like 'sorted paginated query' do - let(:sort_param) { :CONTACTED_ASC } - let(:first_param) { 2 } - let(:expected_results) { ordered_runners.map(&:id) } + let(:sort_param) { :CONTACTED_ASC } + let(:first_param) { 2 } + let(:all_records) { ordered_runners.map(&:id) } end end @@ -105,9 +105,9 @@ RSpec.describe 'Query.runners' do let(:ordered_runners) { runners.sort_by(&:created_at).reverse } it_behaves_like 'sorted paginated query' do - let(:sort_param) { :CREATED_DESC } - let(:first_param) { 2 } - let(:expected_results) { ordered_runners.map(&:id) } + let(:sort_param) { :CREATED_DESC } + let(:first_param) { 2 } + let(:all_records) { ordered_runners.map(&:id) } end end end 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 356e1e11def..d93afcc0f33 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 @@ -153,4 +153,6 @@ RSpec.describe 'container repository details' do end end end + + it_behaves_like 'handling graphql network errors with the container registry' end diff --git a/spec/requests/api/graphql/group/container_repositories_spec.rb b/spec/requests/api/graphql/group/container_repositories_spec.rb index 939d7791d92..be0b866af4a 100644 --- a/spec/requests/api/graphql/group/container_repositories_spec.rb +++ b/spec/requests/api/graphql/group/container_repositories_spec.rb @@ -14,11 +14,12 @@ RSpec.describe 'getting container repositories in a group' do let_it_be(:container_repositories) { [container_repository, container_repositories_delete_scheduled, container_repositories_delete_failed].flatten } let_it_be(:container_expiration_policy) { project.container_expiration_policy } + let(:excluded_fields) { [] } let(:container_repositories_fields) do <<~GQL edges { node { - #{all_graphql_fields_for('container_repositories'.classify, max_depth: 1)} + #{all_graphql_fields_for('container_repositories'.classify, max_depth: 1, excluded: excluded_fields)} } } GQL @@ -152,6 +153,12 @@ RSpec.describe 'getting container repositories in a group' do end end + it_behaves_like 'handling graphql network errors with the container registry' + + it_behaves_like 'not hitting graphql network errors with the container registry' do + let(:excluded_fields) { %w[tags tagsCount] } + end + it 'returns the total count of container repositories' do subject diff --git a/spec/requests/api/graphql/group/dependency_proxy_group_setting_spec.rb b/spec/requests/api/graphql/group/dependency_proxy_group_setting_spec.rb index c5c6d85d1e6..de3dbc5c324 100644 --- a/spec/requests/api/graphql/group/dependency_proxy_group_setting_spec.rb +++ b/spec/requests/api/graphql/group/dependency_proxy_group_setting_spec.rb @@ -33,46 +33,59 @@ RSpec.describe 'getting dependency proxy settings for a group' do before do stub_config(dependency_proxy: { enabled: true }) - group.create_dependency_proxy_setting!(enabled: true) end subject { post_graphql(query, current_user: user, variables: variables) } - it_behaves_like 'a working graphql query' do - before do - subject - end - end - - context 'with different permissions' do - where(:group_visibility, :role, :access_granted) do - :private | :maintainer | true - :private | :developer | true - :private | :reporter | true - :private | :guest | true - :private | :anonymous | false - :public | :maintainer | true - :public | :developer | true - :public | :reporter | true - :public | :guest | true - :public | :anonymous | false + shared_examples 'dependency proxy group setting query' do + it_behaves_like 'a working graphql query' do + before do + subject + end end - with_them do - before do - group.update_column(:visibility_level, Gitlab::VisibilityLevel.const_get(group_visibility.to_s.upcase, false)) - group.add_user(user, role) unless role == :anonymous + context 'with different permissions' do + where(:group_visibility, :role, :access_granted) do + :private | :maintainer | true + :private | :developer | true + :private | :reporter | true + :private | :guest | true + :private | :anonymous | false + :public | :maintainer | true + :public | :developer | true + :public | :reporter | true + :public | :guest | true + :public | :anonymous | false end - it 'return the proper response' do - subject + with_them do + before do + group.update_column(:visibility_level, Gitlab::VisibilityLevel.const_get(group_visibility.to_s.upcase, false)) + group.add_user(user, role) unless role == :anonymous + end + + it 'return the proper response' do + subject - if access_granted - expect(dependency_proxy_group_setting_response).to eq('enabled' => true) - else - expect(dependency_proxy_group_setting_response).to be_blank + if access_granted + expect(dependency_proxy_group_setting_response).to eq('enabled' => true) + else + expect(dependency_proxy_group_setting_response).to be_blank + end end end end end + + context 'with the settings model created' do + before do + group.create_dependency_proxy_setting!(enabled: true) + end + + it_behaves_like 'dependency proxy group setting query' + end + + context 'without the settings model created' do + it_behaves_like 'dependency proxy group setting query' + end end diff --git a/spec/requests/api/graphql/group/issues_spec.rb b/spec/requests/api/graphql/group/issues_spec.rb new file mode 100644 index 00000000000..332bf242e9c --- /dev/null +++ b/spec/requests/api/graphql/group/issues_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'getting an issue list for a group' do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:group1) { create(:group) } + let_it_be(:group2) { create(:group) } + let_it_be(:project1) { create(:project, :public, group: group1) } + let_it_be(:project2) { create(:project, :private, group: group1) } + let_it_be(:project3) { create(:project, :public, group: group2) } + let_it_be(:issue1) { create(:issue, project: project1) } + let_it_be(:issue2) { create(:issue, project: project2) } + let_it_be(:issue3) { create(:issue, project: project3) } + + let(:issue1_gid) { issue1.to_global_id.to_s } + let(:issue2_gid) { issue2.to_global_id.to_s } + let(:issues_data) { graphql_data['group']['issues']['edges'] } + let(:issue_filter_params) { {} } + + let(:fields) do + <<~QUERY + edges { + node { + #{all_graphql_fields_for('issues'.classify)} + } + } + QUERY + end + + let(:query) do + graphql_query_for( + 'group', + { 'fullPath' => group1.full_path }, + query_graphql_field('issues', issue_filter_params, fields) + ) + end + + it_behaves_like 'a working graphql query' do + before do + post_graphql(query, current_user: current_user) + end + end + + context 'when there is a confidential issue' do + let_it_be(:confidential_issue1) { create(:issue, :confidential, project: project1) } + let_it_be(:confidential_issue2) { create(:issue, :confidential, project: project2) } + let_it_be(:confidential_issue3) { create(:issue, :confidential, project: project3) } + + let(:confidential_issue1_gid) { confidential_issue1.to_global_id.to_s } + let(:confidential_issue2_gid) { confidential_issue2.to_global_id.to_s } + + context 'when the user cannot see confidential issues' do + before do + group1.add_guest(current_user) + end + + it 'returns issues without confidential issues for the group' do + post_graphql(query, current_user: current_user) + + expect(issues_ids).to contain_exactly(issue1_gid, issue2_gid) + end + + context 'filtering for confidential issues' do + let(:issue_filter_params) { { confidential: true } } + + it 'returns no issues' do + post_graphql(query, current_user: current_user) + + expect(issues_ids).to be_empty + end + end + + context 'filtering for non-confidential issues' do + let(:issue_filter_params) { { confidential: false } } + + it 'returns correctly filtered issues' do + post_graphql(query, current_user: current_user) + + expect(issues_ids).to contain_exactly(issue1_gid, issue2_gid) + end + end + end + + context 'when the user can see confidential issues' do + before do + group1.add_developer(current_user) + end + + it 'returns issues with confidential issues for the group' do + post_graphql(query, current_user: current_user) + + expect(issues_ids).to contain_exactly(issue1_gid, issue2_gid, confidential_issue1_gid, confidential_issue2_gid) + end + + context 'filtering for confidential issues' do + let(:issue_filter_params) { { confidential: true } } + + it 'returns correctly filtered issues' do + post_graphql(query, current_user: current_user) + + expect(issues_ids).to contain_exactly(confidential_issue1_gid, confidential_issue2_gid) + end + end + + context 'filtering for non-confidential issues' do + let(:issue_filter_params) { { confidential: false } } + + it 'returns correctly filtered issues' do + post_graphql(query, current_user: current_user) + + expect(issues_ids).to contain_exactly(issue1_gid, issue2_gid) + end + end + end + end + + def issues_ids + graphql_dig_at(issues_data, :node, :id) + end +end diff --git a/spec/requests/api/graphql/mutations/ci/runners_registration_token/reset_spec.rb b/spec/requests/api/graphql/mutations/ci/runners_registration_token/reset_spec.rb index 07b05ead651..0fd8fdc3f59 100644 --- a/spec/requests/api/graphql/mutations/ci/runners_registration_token/reset_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/runners_registration_token/reset_spec.rb @@ -89,7 +89,7 @@ RSpec.describe 'RunnersRegistrationTokenReset' do end include_context 'when authorized', 'group' do - let_it_be(:user) { create_default(:group_member, :maintainer, user: create(:user), group: group ).user } + let_it_be(:user) { create_default(:group_member, :owner, user: create(:user), group: group ).user } def get_token group.reload.runners_token diff --git a/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb b/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb new file mode 100644 index 00000000000..aac8eb22771 --- /dev/null +++ b/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Create a new cluster agent token' do + include GraphqlHelpers + + let_it_be(:cluster_agent) { create(:cluster_agent) } + let_it_be(:current_user) { create(:user) } + + let(:description) { 'create token' } + let(:name) { 'token name' } + let(:mutation) do + graphql_mutation( + :cluster_agent_token_create, + { cluster_agent_id: cluster_agent.to_global_id.to_s, description: description, name: name } + ) + end + + def mutation_response + graphql_mutation_response(:cluster_agent_token_create) + end + + context 'without user permissions' do + it_behaves_like 'a mutation that returns top-level errors', + errors: ["The resource that you are attempting to access does not exist "\ + "or you don't have permission to perform this action"] + + it 'does not create a token' do + expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Clusters::AgentToken, :count) + end + end + + context 'with project permissions' do + before do + cluster_agent.project.add_maintainer(current_user) + end + + it 'creates a new token', :aggregate_failures do + expect { post_graphql_mutation(mutation, current_user: current_user) }.to change { Clusters::AgentToken.count }.by(1) + expect(mutation_response['errors']).to eq([]) + end + + it 'returns token information', :aggregate_failures do + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response['secret']).not_to be_nil + expect(mutation_response.dig('token', 'description')).to eq(description) + expect(mutation_response.dig('token', 'name')).to eq(name) + end + end +end diff --git a/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb b/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb new file mode 100644 index 00000000000..c2ef2362d66 --- /dev/null +++ b/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Create a new cluster agent' do + include GraphqlHelpers + + let(:project) { create(:project, :public, :repository) } + let(:project_name) { 'agent-test' } + let(:current_user) { create(:user) } + + let(:mutation) do + graphql_mutation( + :create_cluster_agent, + { project_path: project.full_path, name: project_name } + ) + end + + def mutation_response + graphql_mutation_response(:create_cluster_agent) + end + + context 'without project permissions' do + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not create cluster agent' do + expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Clusters::Agent, :count) + end + end + + context 'with user permissions' do + before do + project.add_maintainer(current_user) + end + + it 'creates a new cluster agent', :aggregate_failures do + expect { post_graphql_mutation(mutation, current_user: current_user) }.to change { Clusters::Agent.count }.by(1) + expect(mutation_response.dig('clusterAgent', 'name')).to eq(project_name) + expect(mutation_response['errors']).to eq([]) + end + end +end diff --git a/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb b/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb new file mode 100644 index 00000000000..5f6822223ca --- /dev/null +++ b/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Delete a cluster agent' do + include GraphqlHelpers + + let(:cluster_agent) { create(:cluster_agent) } + let(:project) { cluster_agent.project } + let(:current_user) { create(:user) } + + let(:mutation) do + graphql_mutation( + :cluster_agent_delete, + { id: cluster_agent.to_global_id.uri } + ) + end + + def mutation_response + graphql_mutation_response(:cluster_agent_delete) + end + + context 'without project permissions' do + it_behaves_like 'a mutation that returns top-level errors', + errors: ['The resource that you are attempting to access does not exist '\ + 'or you don\'t have permission to perform this action'] + + it 'does not delete cluster agent' do + expect { cluster_agent.reload }.not_to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'with project permissions' do + before do + project.add_maintainer(current_user) + end + + it 'deletes a cluster agent', :aggregate_failures do + expect { post_graphql_mutation(mutation, current_user: current_user) }.to change { Clusters::Agent.count }.by(-1) + expect(mutation_response['errors']).to eq([]) + end + end +end diff --git a/spec/requests/api/graphql/mutations/dependency_proxy/group_settings/update_spec.rb b/spec/requests/api/graphql/mutations/dependency_proxy/group_settings/update_spec.rb new file mode 100644 index 00000000000..f05bf23ad27 --- /dev/null +++ b/spec/requests/api/graphql/mutations/dependency_proxy/group_settings/update_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Updating the dependency proxy group settings' do + include GraphqlHelpers + using RSpec::Parameterized::TableSyntax + + let_it_be(:user) { create(:user) } + + let(:params) do + { + group_path: group.full_path, + enabled: false + } + end + + let(:mutation) do + graphql_mutation(:update_dependency_proxy_settings, params) do + <<~QL + dependencyProxySetting { + enabled + } + errors + QL + end + end + + let(:mutation_response) { graphql_mutation_response(:update_dependency_proxy_settings) } + let(:group_settings) { mutation_response['dependencyProxySetting'] } + + before do + stub_config(dependency_proxy: { enabled: true }) + end + + describe 'post graphql mutation' do + subject { post_graphql_mutation(mutation, current_user: user) } + + let_it_be_with_reload(:group) { create(:group) } + let_it_be_with_reload(:group_settings) { create(:dependency_proxy_group_setting, group: group) } + + context 'without permission' do + it 'returns no response' do + subject + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response).to be_nil + end + end + + context 'with permission' do + before do + group.add_developer(user) + end + + it 'returns the updated dependency proxy settings', :aggregate_failures do + subject + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['errors']).to be_empty + expect(group_settings[:enabled]).to eq(false) + end + end + end +end diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb index dec9afd1310..608b36e4f15 100644 --- a/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb +++ b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb @@ -115,7 +115,7 @@ RSpec.describe 'Setting assignees of a merge request', :assume_throttled do context 'when passing append as true' do let(:mode) { Types::MutationOperationModeEnum.enum[:append] } let(:input) { { assignee_usernames: [assignee2.username], operation_mode: mode } } - let(:db_query_limit) { 21 } + let(:db_query_limit) { 22 } before do # In CE, APPEND is a NOOP as you can't have multiple assignees diff --git a/spec/requests/api/graphql/namespace/projects_spec.rb b/spec/requests/api/graphql/namespace/projects_spec.rb index 414847c9c93..d5410f1a7cb 100644 --- a/spec/requests/api/graphql/namespace/projects_spec.rb +++ b/spec/requests/api/graphql/namespace/projects_spec.rb @@ -106,10 +106,10 @@ RSpec.describe 'getting projects' do context 'when sorting by similarity' do it_behaves_like 'sorted paginated query' do - let(:node_path) { %w[name] } - let(:sort_param) { :SIMILARITY } - let(:first_param) { 2 } - let(:expected_results) { [project_3.name, project_2.name, project_4.name] } + let(:node_path) { %w[name] } + let(:sort_param) { :SIMILARITY } + let(:first_param) { 2 } + let(:all_records) { [project_3.name, project_2.name, project_4.name] } end end end diff --git a/spec/requests/api/graphql/project/cluster_agents_spec.rb b/spec/requests/api/graphql/project/cluster_agents_spec.rb new file mode 100644 index 00000000000..dc7254dd552 --- /dev/null +++ b/spec/requests/api/graphql/project/cluster_agents_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Project.cluster_agents' do + include GraphqlHelpers + + let_it_be(:project) { create(:project, :public) } + let_it_be(:current_user) { create(:user, maintainer_projects: [project]) } + let_it_be(:agents) { create_list(:cluster_agent, 5, project: project) } + + let(:first) { var('Int') } + let(:cluster_agents_fields) { nil } + let(:project_fields) do + query_nodes(:cluster_agents, cluster_agents_fields, args: { first: first }, max_depth: 3) + end + + let(:query) do + args = { full_path: project.full_path } + + with_signature([first], graphql_query_for(:project, args, project_fields)) + end + + before do + allow(Gitlab::Kas::Client).to receive(:new).and_return(double(get_connected_agents: [])) + end + + it 'can retrieve cluster agents' do + post_graphql(query, current_user: current_user) + + expect(graphql_data_at(:project, :cluster_agents, :nodes)).to match_array( + agents.map { |agent| a_hash_including('id' => global_id_of(agent)) } + ) + end + + context 'selecting page info' do + let(:project_fields) do + query_nodes(:cluster_agents, args: { first: first }, include_pagination_info: true) + end + + it 'can paginate cluster agents' do + post_graphql(query, current_user: current_user, variables: first.with(2)) + + expect(graphql_data_at(:project, :cluster_agents, :page_info)).to include( + 'hasNextPage' => be_truthy, + 'hasPreviousPage' => be_falsey + ) + expect(graphql_data_at(:project, :cluster_agents, :nodes)).to have_attributes(size: 2) + end + end + + context 'selecting tokens' do + let_it_be(:token_1) { create(:cluster_agent_token, agent: agents.second) } + let_it_be(:token_2) { create(:cluster_agent_token, agent: agents.second, last_used_at: 3.days.ago) } + let_it_be(:token_3) { create(:cluster_agent_token, agent: agents.second, last_used_at: 2.days.ago) } + + let(:cluster_agents_fields) { [:id, query_nodes(:tokens, of: 'ClusterAgentToken')] } + + it 'can select tokens in last_used_at order' do + post_graphql(query, current_user: current_user) + + tokens = graphql_data_at(:project, :cluster_agents, :nodes, :tokens, :nodes) + + expect(tokens).to match([ + a_hash_including('id' => global_id_of(token_3)), + a_hash_including('id' => global_id_of(token_2)), + a_hash_including('id' => global_id_of(token_1)) + ]) + end + + it 'does not suffer from N+1 performance issues' do + post_graphql(query, current_user: current_user) + + expect do + post_graphql(query, current_user: current_user) + end.to issue_same_number_of_queries_as { post_graphql(query, current_user: current_user, variables: [first.with(1)]) } + end + end + + context 'selecting connections' do + let(:agent_meta) { double(version: '1', commit_id: 'abc', pod_namespace: 'namespace', pod_name: 'pod') } + let(:connected_agent) { double(agent_id: agents.first.id, connected_at: 123456, connection_id: 1, agent_meta: agent_meta) } + + let(:metadata_fields) { query_graphql_field(:metadata, {}, [:version, :commit, :pod_namespace, :pod_name], 'AgentMetadata') } + let(:cluster_agents_fields) { [:id, query_nodes(:connections, [:connection_id, :connected_at, metadata_fields])] } + + before do + allow(Gitlab::Kas::Client).to receive(:new).and_return(double(get_connected_agents: [connected_agent])) + end + + it 'can retrieve connections and agent metadata' do + post_graphql(query, current_user: current_user) + + connection = graphql_data_at(:project, :cluster_agents, :nodes, :connections, :nodes).first + + expect(connection).to include({ + 'connectionId' => connected_agent.connection_id.to_s, + 'connectedAt' => Time.at(connected_agent.connected_at), + 'metadata' => { + 'version' => agent_meta.version, + 'commit' => agent_meta.commit_id, + 'podNamespace' => agent_meta.pod_namespace, + 'podName' => agent_meta.pod_name + } + }) + end + end +end diff --git a/spec/requests/api/graphql/project/container_repositories_spec.rb b/spec/requests/api/graphql/project/container_repositories_spec.rb index 3ad56223b61..692143b2215 100644 --- a/spec/requests/api/graphql/project/container_repositories_spec.rb +++ b/spec/requests/api/graphql/project/container_repositories_spec.rb @@ -12,11 +12,12 @@ RSpec.describe 'getting container repositories in a project' do let_it_be(:container_repositories) { [container_repository, container_repositories_delete_scheduled, container_repositories_delete_failed].flatten } let_it_be(:container_expiration_policy) { project.container_expiration_policy } + let(:excluded_fields) { %w[pipeline jobs] } let(:container_repositories_fields) do <<~GQL edges { node { - #{all_graphql_fields_for('container_repositories'.classify, excluded: %w(pipeline jobs))} + #{all_graphql_fields_for('container_repositories'.classify, excluded: excluded_fields)} } } GQL @@ -151,6 +152,12 @@ RSpec.describe 'getting container repositories in a project' do end end + it_behaves_like 'handling graphql network errors with the container registry' + + it_behaves_like 'not hitting graphql network errors with the container registry' do + let(:excluded_fields) { %w[pipeline jobs tags tagsCount] } + end + it 'returns the total count of container repositories' do subject @@ -190,7 +197,7 @@ RSpec.describe 'getting container repositories in a project' do it_behaves_like 'sorted paginated query' do let(:sort_param) { :NAME_ASC } let(:first_param) { 2 } - let(:expected_results) { [container_repository2.name, container_repository1.name, container_repository4.name, container_repository3.name, container_repository5.name] } + let(:all_records) { [container_repository2.name, container_repository1.name, container_repository4.name, container_repository3.name, container_repository5.name] } end end @@ -198,7 +205,7 @@ RSpec.describe 'getting container repositories in a project' do it_behaves_like 'sorted paginated query' do let(:sort_param) { :NAME_DESC } let(:first_param) { 2 } - let(:expected_results) { [container_repository5.name, container_repository3.name, container_repository4.name, container_repository1.name, container_repository2.name] } + let(:all_records) { [container_repository5.name, container_repository3.name, container_repository4.name, container_repository1.name, container_repository2.name] } end end end diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb index c6b4d82bf15..1c6d6ce4707 100644 --- a/spec/requests/api/graphql/project/issues_spec.rb +++ b/spec/requests/api/graphql/project/issues_spec.rb @@ -5,12 +5,15 @@ require 'spec_helper' RSpec.describe 'getting an issue list for a project' do include GraphqlHelpers - let_it_be(:project) { create(:project, :repository, :public) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :repository, :public, group: group) } let_it_be(:current_user) { create(:user) } let_it_be(:issue_a, reload: true) { create(:issue, project: project, discussion_locked: true) } let_it_be(:issue_b, reload: true) { create(:issue, :with_alert, project: project) } let_it_be(:issues, reload: true) { [issue_a, issue_b] } + let(:issue_a_gid) { issue_a.to_global_id.to_s } + let(:issue_b_gid) { issue_b.to_global_id.to_s } let(:issues_data) { graphql_data['project']['issues']['edges'] } let(:issue_filter_params) { {} } @@ -66,9 +69,6 @@ RSpec.describe 'getting an issue list for a project' do let_it_be(:upvote_award) { create(:award_emoji, :upvote, user: current_user, awardable: issue_a) } - let(:issue_a_gid) { issue_a.to_global_id.to_s } - let(:issue_b_gid) { issue_b.to_global_id.to_s } - where(:value, :gids) do 'thumbsup' | lazy { [issue_a_gid] } 'ANY' | lazy { [issue_a_gid] } @@ -84,7 +84,7 @@ RSpec.describe 'getting an issue list for a project' do it 'returns correctly filtered issues' do post_graphql(query, current_user: current_user) - expect(graphql_dig_at(issues_data, :node, :id)).to eq(gids) + expect(issues_ids).to eq(gids) end end end @@ -149,6 +149,8 @@ RSpec.describe 'getting an issue list for a project' do create(:issue, :confidential, project: project) end + let(:confidential_issue_gid) { confidential_issue.to_global_id.to_s } + context 'when the user cannot see confidential issues' do it 'returns issues without confidential issues' do post_graphql(query, current_user: current_user) @@ -159,12 +161,34 @@ RSpec.describe 'getting an issue list for a project' do expect(issue.dig('node', 'confidential')).to eq(false) end end + + context 'filtering for confidential issues' do + let(:issue_filter_params) { { confidential: true } } + + it 'returns no issues' do + post_graphql(query, current_user: current_user) + + expect(issues_data.size).to eq(0) + end + end + + context 'filtering for non-confidential issues' do + let(:issue_filter_params) { { confidential: false } } + + it 'returns correctly filtered issues' do + post_graphql(query, current_user: current_user) + + expect(issues_ids).to contain_exactly(issue_a_gid, issue_b_gid) + end + end end context 'when the user can see confidential issues' do - it 'returns issues with confidential issues' do + before do project.add_developer(current_user) + end + it 'returns issues with confidential issues' do post_graphql(query, current_user: current_user) expect(issues_data.size).to eq(3) @@ -175,6 +199,26 @@ RSpec.describe 'getting an issue list for a project' do expect(confidentials).to eq([true, false, false]) end + + context 'filtering for confidential issues' do + let(:issue_filter_params) { { confidential: true } } + + it 'returns correctly filtered issues' do + post_graphql(query, current_user: current_user) + + expect(issues_ids).to contain_exactly(confidential_issue_gid) + end + end + + context 'filtering for non-confidential issues' do + let(:issue_filter_params) { { confidential: false } } + + it 'returns correctly filtered issues' do + post_graphql(query, current_user: current_user) + + expect(issues_ids).to contain_exactly(issue_a_gid, issue_b_gid) + end + end end end @@ -205,7 +249,7 @@ RSpec.describe 'getting an issue list for a project' do it_behaves_like 'sorted paginated query' do let(:sort_param) { :DUE_DATE_ASC } let(:first_param) { 2 } - let(:expected_results) { [due_issue3.iid, due_issue5.iid, due_issue1.iid, due_issue4.iid, due_issue2.iid] } + let(:all_records) { [due_issue3.iid, due_issue5.iid, due_issue1.iid, due_issue4.iid, due_issue2.iid] } end end @@ -213,7 +257,7 @@ RSpec.describe 'getting an issue list for a project' do it_behaves_like 'sorted paginated query' do let(:sort_param) { :DUE_DATE_DESC } let(:first_param) { 2 } - let(:expected_results) { [due_issue1.iid, due_issue5.iid, due_issue3.iid, due_issue4.iid, due_issue2.iid] } + let(:all_records) { [due_issue1.iid, due_issue5.iid, due_issue3.iid, due_issue4.iid, due_issue2.iid] } end end end @@ -230,10 +274,10 @@ RSpec.describe 'getting an issue list for a project' do it_behaves_like 'sorted paginated query' do let(:sort_param) { :RELATIVE_POSITION_ASC } let(:first_param) { 2 } - let(:expected_results) do + let(:all_records) do [ relative_issue5.iid, relative_issue3.iid, relative_issue1.iid, - relative_issue4.iid, relative_issue2.iid + relative_issue2.iid, relative_issue4.iid ] end end @@ -256,7 +300,7 @@ RSpec.describe 'getting an issue list for a project' do it_behaves_like 'sorted paginated query' do let(:sort_param) { :PRIORITY_ASC } let(:first_param) { 2 } - let(:expected_results) do + let(:all_records) do [ priority_issue3.iid, priority_issue1.iid, priority_issue2.iid, priority_issue4.iid @@ -269,7 +313,7 @@ RSpec.describe 'getting an issue list for a project' do it_behaves_like 'sorted paginated query' do let(:sort_param) { :PRIORITY_DESC } let(:first_param) { 2 } - let(:expected_results) do + let(:all_records) do [priority_issue1.iid, priority_issue3.iid, priority_issue2.iid, priority_issue4.iid] end end @@ -288,17 +332,17 @@ RSpec.describe 'getting an issue list for a project' do context 'when ascending' do it_behaves_like 'sorted paginated query' do - let(:sort_param) { :LABEL_PRIORITY_ASC } - let(:first_param) { 2 } - let(:expected_results) { [label_issue3.iid, label_issue1.iid, label_issue2.iid, label_issue4.iid] } + let(:sort_param) { :LABEL_PRIORITY_ASC } + let(:first_param) { 2 } + let(:all_records) { [label_issue3.iid, label_issue1.iid, label_issue2.iid, label_issue4.iid] } end end context 'when descending' do it_behaves_like 'sorted paginated query' do - let(:sort_param) { :LABEL_PRIORITY_DESC } - let(:first_param) { 2 } - let(:expected_results) { [label_issue2.iid, label_issue3.iid, label_issue1.iid, label_issue4.iid] } + let(:sort_param) { :LABEL_PRIORITY_DESC } + let(:first_param) { 2 } + let(:all_records) { [label_issue2.iid, label_issue3.iid, label_issue1.iid, label_issue4.iid] } end end end @@ -313,17 +357,17 @@ RSpec.describe 'getting an issue list for a project' do context 'when ascending' do it_behaves_like 'sorted paginated query' do - let(:sort_param) { :MILESTONE_DUE_ASC } - let(:first_param) { 2 } - let(:expected_results) { [milestone_issue2.iid, milestone_issue3.iid, milestone_issue1.iid] } + let(:sort_param) { :MILESTONE_DUE_ASC } + let(:first_param) { 2 } + let(:all_records) { [milestone_issue2.iid, milestone_issue3.iid, milestone_issue1.iid] } end end context 'when descending' do it_behaves_like 'sorted paginated query' do - let(:sort_param) { :MILESTONE_DUE_DESC } - let(:first_param) { 2 } - let(:expected_results) { [milestone_issue3.iid, milestone_issue2.iid, milestone_issue1.iid] } + let(:sort_param) { :MILESTONE_DUE_DESC } + let(:first_param) { 2 } + let(:all_records) { [milestone_issue3.iid, milestone_issue2.iid, milestone_issue1.iid] } end end end @@ -366,6 +410,35 @@ RSpec.describe 'getting an issue list for a project' do end end + context 'when fetching customer_relations_contacts' do + let(:fields) do + <<~QUERY + nodes { + id + customerRelationsContacts { + nodes { + firstName + } + } + } + QUERY + end + + def clean_state_query + run_with_clean_state(query, context: { current_user: current_user }) + end + + it 'avoids N+1 queries' do + create(:contact, group_id: group.id, issues: [issue_a]) + + control = ActiveRecord::QueryRecorder.new(skip_cached: false) { clean_state_query } + + create(:contact, group_id: group.id, issues: [issue_a]) + + expect { clean_state_query }.not_to exceed_all_query_limit(control) + end + end + context 'when fetching labels' do let(:fields) do <<~QUERY @@ -526,4 +599,8 @@ RSpec.describe 'getting an issue list for a project' do include_examples 'N+1 query check' end end + + def issues_ids + graphql_dig_at(issues_data, :node, :id) + end end diff --git a/spec/requests/api/graphql/project/merge_request/pipelines_spec.rb b/spec/requests/api/graphql/project/merge_request/pipelines_spec.rb index 70c5bda35e1..820a5d818c7 100644 --- a/spec/requests/api/graphql/project/merge_request/pipelines_spec.rb +++ b/spec/requests/api/graphql/project/merge_request/pipelines_spec.rb @@ -39,7 +39,7 @@ RSpec.describe 'Query.project.mergeRequests.pipelines' do before do merge_requests.each do |mr| - shas = mr.all_commits.limit(2).pluck(:sha) + shas = mr.recent_diff_head_shas shas.each do |sha| create(:ci_pipeline, :success, project: project, ref: mr.source_branch, sha: sha) @@ -52,7 +52,7 @@ RSpec.describe 'Query.project.mergeRequests.pipelines' do p_nodes = graphql_data_at(:project, :merge_requests, :nodes) - expect(p_nodes).to all(match('iid' => be_present, 'pipelines' => match('count' => 2))) + expect(p_nodes).to all(match('iid' => be_present, 'pipelines' => match('count' => 1))) end it 'is scalable', :request_store, :use_clean_rails_memory_store_caching do diff --git a/spec/requests/api/graphql/project/merge_requests_spec.rb b/spec/requests/api/graphql/project/merge_requests_spec.rb index 1b0405be09c..b0bedd99fce 100644 --- a/spec/requests/api/graphql/project/merge_requests_spec.rb +++ b/spec/requests/api/graphql/project/merge_requests_spec.rb @@ -385,7 +385,7 @@ RSpec.describe 'getting merge request listings nested in a project' do context 'when sorting by merged_at DESC' do let(:sort_param) { :MERGED_AT_DESC } - let(:expected_results) do + let(:all_records) do [ merge_request_b, merge_request_d, @@ -418,14 +418,14 @@ RSpec.describe 'getting merge request listings nested in a project' do query = pagination_query(params) post_graphql(query, current_user: current_user) - expect(results.map { |item| item["id"] }).to eq(expected_results.last(2)) + expect(results.map { |item| item["id"] }).to eq(all_records.last(2)) end end end context 'when sorting by closed_at DESC' do let(:sort_param) { :CLOSED_AT_DESC } - let(:expected_results) do + let(:all_records) do [ merge_request_b, merge_request_d, @@ -458,7 +458,7 @@ RSpec.describe 'getting merge request listings nested in a project' do query = pagination_query(params) post_graphql(query, current_user: current_user) - expect(results.map { |item| item["id"] }).to eq(expected_results.last(2)) + expect(results.map { |item| item["id"] }).to eq(all_records.last(2)) end end end diff --git a/spec/requests/api/graphql/project/releases_spec.rb b/spec/requests/api/graphql/project/releases_spec.rb index 8ccdb955ed9..2816ce90a6b 100644 --- a/spec/requests/api/graphql/project/releases_spec.rb +++ b/spec/requests/api/graphql/project/releases_spec.rb @@ -322,17 +322,17 @@ RSpec.describe 'Query.project(fullPath).releases()' do context 'when ascending' do it_behaves_like 'sorted paginated query' do - let(:sort_param) { :RELEASED_AT_ASC } - let(:first_param) { 2 } - let(:expected_results) { [release1.tag, release2.tag, release3.tag, release4.tag, release5.tag] } + let(:sort_param) { :RELEASED_AT_ASC } + let(:first_param) { 2 } + let(:all_records) { [release1.tag, release2.tag, release3.tag, release4.tag, release5.tag] } end end context 'when descending' do it_behaves_like 'sorted paginated query' do - let(:sort_param) { :RELEASED_AT_DESC } - let(:first_param) { 2 } - let(:expected_results) { [release5.tag, release4.tag, release3.tag, release2.tag, release1.tag] } + let(:sort_param) { :RELEASED_AT_DESC } + let(:first_param) { 2 } + let(:all_records) { [release5.tag, release4.tag, release3.tag, release2.tag, release1.tag] } end end end @@ -346,17 +346,17 @@ RSpec.describe 'Query.project(fullPath).releases()' do context 'when ascending' do it_behaves_like 'sorted paginated query' do - let(:sort_param) { :CREATED_ASC } - let(:first_param) { 2 } - let(:expected_results) { [release1.tag, release2.tag, release3.tag, release4.tag, release5.tag] } + let(:sort_param) { :CREATED_ASC } + let(:first_param) { 2 } + let(:all_records) { [release1.tag, release2.tag, release3.tag, release4.tag, release5.tag] } end end context 'when descending' do it_behaves_like 'sorted paginated query' do - let(:sort_param) { :CREATED_DESC } - let(:first_param) { 2 } - let(:expected_results) { [release5.tag, release4.tag, release3.tag, release2.tag, release1.tag] } + let(:sort_param) { :CREATED_DESC } + let(:first_param) { 2 } + let(:all_records) { [release5.tag, release4.tag, release3.tag, release2.tag, release1.tag] } end end end diff --git a/spec/requests/api/graphql/users_spec.rb b/spec/requests/api/graphql/users_spec.rb index 22b68fbc9bb..67cd35ee545 100644 --- a/spec/requests/api/graphql/users_spec.rb +++ b/spec/requests/api/graphql/users_spec.rb @@ -114,17 +114,17 @@ RSpec.describe 'Users' do context 'when ascending' do it_behaves_like 'sorted paginated query' do - let(:sort_param) { :CREATED_ASC } - let(:first_param) { 1 } - let(:expected_results) { ascending_users } + let(:sort_param) { :CREATED_ASC } + let(:first_param) { 1 } + let(:all_records) { ascending_users } end end context 'when descending' do it_behaves_like 'sorted paginated query' do - let(:sort_param) { :CREATED_DESC } - let(:first_param) { 1 } - let(:expected_results) { ascending_users.reverse } + let(:sort_param) { :CREATED_DESC } + let(:first_param) { 1 } + let(:all_records) { ascending_users.reverse } end end end diff --git a/spec/requests/api/group_container_repositories_spec.rb b/spec/requests/api/group_container_repositories_spec.rb index fdbf910e4bc..bf29bd91414 100644 --- a/spec/requests/api/group_container_repositories_spec.rb +++ b/spec/requests/api/group_container_repositories_spec.rb @@ -20,12 +20,14 @@ RSpec.describe API::GroupContainerRepositories do end let(:api_user) { reporter } + let(:params) { {} } before do group.add_reporter(reporter) group.add_guest(guest) stub_container_registry_config(enabled: true) + stub_container_registry_info root_repository test_repository @@ -35,10 +37,13 @@ RSpec.describe API::GroupContainerRepositories do let(:url) { "/groups/#{group.id}/registry/repositories" } let(:snowplow_gitlab_standard_context) { { user: api_user, namespace: group } } - subject { get api(url, api_user) } + subject { get api(url, api_user), params: params } it_behaves_like 'rejected container repository access', :guest, :forbidden it_behaves_like 'rejected container repository access', :anonymous, :not_found + it_behaves_like 'handling network errors with the container registry' do + let(:params) { { tags: true } } + end it_behaves_like 'returns repositories for allowed users', :reporter, 'group' do let(:object) { group } diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index 38abedde7da..2c7e2ecff85 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -728,16 +728,16 @@ RSpec.describe API::Groups do end it 'avoids N+1 queries with project links' do - get api("/groups/#{group1.id}", admin) + get api("/groups/#{group1.id}", user1) control_count = ActiveRecord::QueryRecorder.new do - get api("/groups/#{group1.id}", admin) + get api("/groups/#{group1.id}", user1) end.count create(:project, namespace: group1) expect do - get api("/groups/#{group1.id}", admin) + get api("/groups/#{group1.id}", user1) end.not_to exceed_query_limit(control_count) end @@ -746,7 +746,7 @@ RSpec.describe API::Groups do create(:group_group_link, shared_group: group1, shared_with_group: create(:group)) control_count = ActiveRecord::QueryRecorder.new do - get api("/groups/#{group1.id}", admin) + get api("/groups/#{group1.id}", user1) end.count # setup "n" more shared groups @@ -755,7 +755,7 @@ RSpec.describe API::Groups do # test that no of queries for 1 shared group is same as for n shared groups expect do - get api("/groups/#{group1.id}", admin) + get api("/groups/#{group1.id}", user1) end.not_to exceed_query_limit(control_count) end end @@ -1179,6 +1179,20 @@ RSpec.describe API::Groups do expect(json_response.length).to eq(1) expect(json_response.first['name']).to eq(project1.name) end + + it 'avoids N+1 queries' do + get api("/groups/#{group1.id}/projects", user1) + + control_count = ActiveRecord::QueryRecorder.new do + get api("/groups/#{group1.id}/projects", user1) + end.count + + create(:project, namespace: group1) + + expect do + get api("/groups/#{group1.id}/projects", user1) + end.not_to exceed_query_limit(control_count) + end end context "when authenticated as admin" do @@ -1196,20 +1210,6 @@ RSpec.describe API::Groups do expect(response).to have_gitlab_http_status(:not_found) end - - it 'avoids N+1 queries' do - get api("/groups/#{group1.id}/projects", admin) - - control_count = ActiveRecord::QueryRecorder.new do - get api("/groups/#{group1.id}/projects", admin) - end.count - - create(:project, namespace: group1) - - expect do - get api("/groups/#{group1.id}/projects", admin) - end.not_to exceed_query_limit(control_count) - end end context 'when using group path in URL' do diff --git a/spec/requests/api/helm_packages_spec.rb b/spec/requests/api/helm_packages_spec.rb index 3236857c5fc..5212e225351 100644 --- a/spec/requests/api/helm_packages_spec.rb +++ b/spec/requests/api/helm_packages_spec.rb @@ -18,11 +18,11 @@ RSpec.describe API::HelmPackages do let_it_be(:other_package) { create(:npm_package, project: project) } describe 'GET /api/v4/projects/:id/packages/helm/:channel/index.yaml' do - let(:url) { "/projects/#{project_id}/packages/helm/stable/index.yaml" } + let(:project_id) { project.id } + let(:channel) { 'stable' } + let(:url) { "/projects/#{project_id}/packages/helm/#{channel}/index.yaml" } context 'with a project id' do - let(:project_id) { project.id } - it_behaves_like 'handling helm chart index requests' end @@ -31,6 +31,18 @@ RSpec.describe API::HelmPackages do it_behaves_like 'handling helm chart index requests' end + + context 'with dot in channel' do + let(:channel) { 'with.dot' } + + subject { get api(url) } + + before do + project.update!(visibility: 'public') + end + + it_behaves_like 'returning response status', :success + end end describe 'GET /api/v4/projects/:id/packages/helm/:channel/charts/:file_name.tgz' do diff --git a/spec/requests/api/integrations_spec.rb b/spec/requests/api/integrations_spec.rb new file mode 100644 index 00000000000..649647804c0 --- /dev/null +++ b/spec/requests/api/integrations_spec.rb @@ -0,0 +1,363 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe API::Integrations do + let_it_be(:user) { create(:user) } + let_it_be(:user2) { create(:user) } + + let_it_be(:project, reload: true) do + create(:project, creator_id: user.id, namespace: user.namespace) + end + + %w[integrations services].each do |endpoint| + describe "GET /projects/:id/#{endpoint}" do + it 'returns authentication error when unauthenticated' do + get api("/projects/#{project.id}/#{endpoint}") + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it "returns error when authenticated but user is not a project owner" do + project.add_developer(user2) + get api("/projects/#{project.id}/#{endpoint}", user2) + + expect(response).to have_gitlab_http_status(:forbidden) + end + + context 'with integrations' do + let!(:active_integration) { create(:emails_on_push_integration, project: project, active: true) } + let!(:integration) { create(:custom_issue_tracker_integration, project: project, active: false) } + + it "returns a list of all active integrations" do + get api("/projects/#{project.id}/#{endpoint}", user) + + aggregate_failures 'expect successful response with all active integrations' do + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + expect(json_response.count).to eq(1) + expect(json_response.first['slug']).to eq('emails-on-push') + expect(response).to match_response_schema('public_api/v4/integrations') + end + end + end + end + + Integration.available_integration_names.each do |integration| + describe "PUT /projects/:id/#{endpoint}/#{integration.dasherize}" do + include_context integration + + it "updates #{integration} settings" do + put api("/projects/#{project.id}/#{endpoint}/#{dashed_integration}", user), params: integration_attrs + + expect(response).to have_gitlab_http_status(:ok) + + current_integration = project.integrations.first + events = current_integration.event_names.empty? ? ["foo"].freeze : current_integration.event_names + query_strings = [] + events.each do |event| + query_strings << "#{event}=#{!current_integration[event]}" + end + query_strings = query_strings.join('&') + + put api("/projects/#{project.id}/#{endpoint}/#{dashed_integration}?#{query_strings}", user), params: integration_attrs + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['slug']).to eq(dashed_integration) + events.each do |event| + next if event == "foo" + + expect(project.integrations.first[event]).not_to eq(current_integration[event]), + "expected #{!current_integration[event]} for event #{event} for #{endpoint} #{current_integration.title}, got #{current_integration[event]}" + end + end + + it "returns if required fields missing" do + required_attributes = integration_attrs_list.select do |attr| + integration_klass.validators_on(attr).any? do |v| + v.instance_of?(ActiveRecord::Validations::PresenceValidator) && + # exclude presence validators with conditional since those are not really required + ![:if, :unless].any? { |cond| v.options.include?(cond) } + end + end + + if required_attributes.empty? + expected_code = :ok + else + integration_attrs.delete(required_attributes.sample) + expected_code = :bad_request + end + + put api("/projects/#{project.id}/#{endpoint}/#{dashed_integration}", user), params: integration_attrs + + expect(response).to have_gitlab_http_status(expected_code) + end + end + + describe "DELETE /projects/:id/#{endpoint}/#{integration.dasherize}" do + include_context integration + + before do + initialize_integration(integration) + end + + it "deletes #{integration}" do + delete api("/projects/#{project.id}/#{endpoint}/#{dashed_integration}", user) + + expect(response).to have_gitlab_http_status(:no_content) + project.send(integration_method).reload + expect(project.send(integration_method).activated?).to be_falsey + end + end + + describe "GET /projects/:id/#{endpoint}/#{integration.dasherize}" do + include_context integration + + let!(:initialized_integration) { initialize_integration(integration, active: true) } + + let_it_be(:project2) do + create(:project, creator_id: user.id, namespace: user.namespace) + end + + def deactive_integration! + return initialized_integration.update!(active: false) unless initialized_integration.is_a?(::Integrations::Prometheus) + + # Integrations::Prometheus sets `#active` itself within a `before_save`: + initialized_integration.manual_configuration = false + initialized_integration.save! + end + + it 'returns authentication error when unauthenticated' do + get api("/projects/#{project.id}/#{endpoint}/#{dashed_integration}") + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it "returns all properties of active integration #{integration}" do + get api("/projects/#{project.id}/#{endpoint}/#{dashed_integration}", user) + + expect(initialized_integration).to be_active + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['properties'].keys).to match_array(integration_instance.api_field_names) + end + + it "returns all properties of inactive integration #{integration}" do + deactive_integration! + + get api("/projects/#{project.id}/#{endpoint}/#{dashed_integration}", user) + + expect(initialized_integration).not_to be_active + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['properties'].keys).to match_array(integration_instance.api_field_names) + end + + it "returns not found if integration does not exist" do + get api("/projects/#{project2.id}/#{endpoint}/#{dashed_integration}", user) + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Integration Not Found') + end + + it "returns not found if integration exists but is in `Project#disabled_integrations`" do + expect_next_found_instance_of(Project) do |project| + expect(project).to receive(:disabled_integrations).at_least(:once).and_return([integration]) + end + + get api("/projects/#{project.id}/#{endpoint}/#{dashed_integration}", user) + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Integration Not Found') + end + + it "returns error when authenticated but not a project owner" do + project.add_developer(user2) + get api("/projects/#{project.id}/#{endpoint}/#{dashed_integration}", user2) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end + + describe "POST /projects/:id/#{endpoint}/:slug/trigger" do + describe 'Mattermost integration' do + let(:integration_name) { 'mattermost_slash_commands' } + + context 'when no integration is available' do + it 'returns a not found message' do + post api("/projects/#{project.id}/#{endpoint}/idonotexist/trigger") + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response["error"]).to eq("404 Not Found") + end + end + + context 'when the integration exists' do + let(:params) { { token: 'token' } } + + context 'when the integration is not active' do + before do + project.create_mattermost_slash_commands_integration( + active: false, + properties: params + ) + end + + it 'when the integration is inactive' do + post api("/projects/#{project.id}/#{endpoint}/#{integration_name}/trigger"), params: params + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when the integration is active' do + before do + project.create_mattermost_slash_commands_integration( + active: true, + properties: params + ) + end + + it 'returns status 200' do + post api("/projects/#{project.id}/#{endpoint}/#{integration_name}/trigger"), params: params + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when the project can not be found' do + it 'returns a generic 404' do + post api("/projects/404/#{endpoint}/#{integration_name}/trigger"), params: params + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response["message"]).to eq("404 Integration Not Found") + end + end + end + end + + describe 'Slack Integration' do + let(:integration_name) { 'slack_slash_commands' } + + before do + project.create_slack_slash_commands_integration( + active: true, + properties: { token: 'token' } + ) + end + + it 'returns status 200' do + post api("/projects/#{project.id}/#{endpoint}/#{integration_name}/trigger"), params: { token: 'token', text: 'help' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['response_type']).to eq("ephemeral") + end + end + end + + describe 'Mattermost integration' do + let(:integration_name) { 'mattermost' } + let(:params) do + { webhook: 'https://hook.example.com', username: 'username' } + end + + before do + project.create_mattermost_integration( + active: true, + properties: params + ) + end + + it 'accepts a username for update' do + put api("/projects/#{project.id}/#{endpoint}/#{integration_name}", user), params: params.merge(username: 'new_username') + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['properties']['username']).to eq('new_username') + end + end + + describe 'Microsoft Teams integration' do + let(:integration_name) { 'microsoft-teams' } + let(:params) do + { + webhook: 'https://hook.example.com', + branches_to_be_notified: 'default', + notify_only_broken_pipelines: false + } + end + + before do + project.create_microsoft_teams_integration( + active: true, + properties: params + ) + end + + it 'accepts branches_to_be_notified for update' do + put api("/projects/#{project.id}/#{endpoint}/#{integration_name}", user), + params: params.merge(branches_to_be_notified: 'all') + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['properties']['branches_to_be_notified']).to eq('all') + end + + it 'accepts notify_only_broken_pipelines for update' do + put api("/projects/#{project.id}/#{endpoint}/#{integration_name}", user), + params: params.merge(notify_only_broken_pipelines: true) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['properties']['notify_only_broken_pipelines']).to eq(true) + end + end + + describe 'Hangouts Chat integration' do + let(:integration_name) { 'hangouts-chat' } + let(:params) do + { + webhook: 'https://hook.example.com', + branches_to_be_notified: 'default' + } + end + + before do + project.create_hangouts_chat_integration( + active: true, + properties: params + ) + end + + it 'accepts branches_to_be_notified for update', :aggregate_failures do + put api("/projects/#{project.id}/#{endpoint}/#{integration_name}", user), params: params.merge(branches_to_be_notified: 'all') + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['properties']['branches_to_be_notified']).to eq('all') + end + + it 'only requires the webhook param' do + put api("/projects/#{project.id}/#{endpoint}/#{integration_name}", user), params: { webhook: 'https://hook.example.com' } + + expect(response).to have_gitlab_http_status(:ok) + end + end + + describe 'Pipelines Email Integration' do + let(:integration_name) { 'pipelines-email' } + + context 'notify_only_broken_pipelines property was saved as a string' do + before do + project.create_pipelines_email_integration( + active: false, + properties: { + "notify_only_broken_pipelines": "true", + "branches_to_be_notified": "default" + } + ) + end + + it 'returns boolean values for notify_only_broken_pipelines' do + get api("/projects/#{project.id}/#{endpoint}/#{integration_name}", user) + + expect(json_response['properties']['notify_only_broken_pipelines']).to eq(true) + end + end + end + end +end diff --git a/spec/requests/api/internal/base_spec.rb b/spec/requests/api/internal/base_spec.rb index 49756df61c6..aeca4e435f4 100644 --- a/spec/requests/api/internal/base_spec.rb +++ b/spec/requests/api/internal/base_spec.rb @@ -609,7 +609,7 @@ RSpec.describe API::Internal::Base do end context 'with Project' do - it_behaves_like 'storing arguments in the application context' do + it_behaves_like 'storing arguments in the application context for the API' do let(:expected_params) { { user: key.user.username, project: project.full_path, caller_id: "POST /api/:version/internal/allowed" } } subject { push(key, project) } @@ -617,7 +617,7 @@ RSpec.describe API::Internal::Base do end context 'with PersonalSnippet' do - it_behaves_like 'storing arguments in the application context' do + it_behaves_like 'storing arguments in the application context for the API' do let(:expected_params) { { user: key.user.username, caller_id: "POST /api/:version/internal/allowed" } } subject { push(key, personal_snippet) } @@ -625,7 +625,7 @@ RSpec.describe API::Internal::Base do end context 'with ProjectSnippet' do - it_behaves_like 'storing arguments in the application context' do + it_behaves_like 'storing arguments in the application context for the API' do let(:expected_params) { { user: key.user.username, project: project_snippet.project.full_path, caller_id: "POST /api/:version/internal/allowed" } } subject { push(key, project_snippet) } @@ -1197,7 +1197,7 @@ RSpec.describe API::Internal::Base do subject end - it_behaves_like 'storing arguments in the application context' do + it_behaves_like 'storing arguments in the application context for the API' do let(:expected_params) { expected_context } end end diff --git a/spec/requests/api/internal/kubernetes_spec.rb b/spec/requests/api/internal/kubernetes_spec.rb index 24422f7b0dd..245e4e6ba15 100644 --- a/spec/requests/api/internal/kubernetes_spec.rb +++ b/spec/requests/api/internal/kubernetes_spec.rb @@ -177,94 +177,4 @@ RSpec.describe API::Internal::Kubernetes do end end end - - describe 'GET /internal/kubernetes/project_info' do - def send_request(headers: {}, params: {}) - get api('/internal/kubernetes/project_info'), params: params, headers: headers.reverse_merge(jwt_auth_headers) - end - - include_examples 'authorization' - include_examples 'agent authentication' - - context 'an agent is found' do - let_it_be(:agent_token) { create(:cluster_agent_token) } - - shared_examples 'agent token tracking' - - context 'project is public' do - let(:project) { create(:project, :public) } - - it 'returns expected data', :aggregate_failures do - send_request(params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }) - - expect(response).to have_gitlab_http_status(:success) - - expect(json_response).to match( - a_hash_including( - 'project_id' => project.id, - 'gitaly_info' => a_hash_including( - 'address' => match(/\.socket$/), - 'token' => 'secret', - 'features' => {} - ), - 'gitaly_repository' => a_hash_including( - 'storage_name' => project.repository_storage, - 'relative_path' => project.disk_path + '.git', - 'gl_repository' => "project-#{project.id}", - 'gl_project_path' => project.full_path - ) - ) - ) - end - - context 'repository is for project members only' do - let(:project) { create(:project, :public, :repository_private) } - - it 'returns 404' do - send_request(params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }) - - expect(response).to have_gitlab_http_status(:not_found) - end - end - end - - context 'project is private' do - let(:project) { create(:project, :private) } - - it 'returns 404' do - send_request(params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }) - - expect(response).to have_gitlab_http_status(:not_found) - end - - context 'and agent belongs to project' do - let(:agent_token) { create(:cluster_agent_token, agent: create(:cluster_agent, project: project)) } - - it 'returns 200' do - send_request(params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }) - - expect(response).to have_gitlab_http_status(:success) - end - end - end - - context 'project is internal' do - let(:project) { create(:project, :internal) } - - it 'returns 404' do - send_request(params: { id: project.id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }) - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'project does not exist' do - it 'returns 404' do - send_request(params: { id: non_existing_record_id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }) - - expect(response).to have_gitlab_http_status(:not_found) - end - end - end - end end diff --git a/spec/requests/api/issues/issues_spec.rb b/spec/requests/api/issues/issues_spec.rb index 8a33e63b80b..9204ee4d7f0 100644 --- a/spec/requests/api/issues/issues_spec.rb +++ b/spec/requests/api/issues/issues_spec.rb @@ -138,6 +138,12 @@ RSpec.describe API::Issues do expect(json_response).to be_an Array end + it_behaves_like 'issuable anonymous search' do + let(:url) { '/issues' } + let(:issuable) { issue } + let(:result) { issuable.id } + end + it 'returns authentication error without any scope' do get api('/issues') @@ -256,6 +262,38 @@ RSpec.describe API::Issues do it_behaves_like 'issues statistics' end + + context 'with search param' do + let(:params) { { scope: 'all', search: 'foo' } } + let(:counts) { { all: 1, closed: 0, opened: 1 } } + + it_behaves_like 'issues statistics' + + context 'with anonymous user' do + let(:user) { nil } + + context 'with disable_anonymous_search disabled' do + before do + stub_feature_flags(disable_anonymous_search: false) + end + + it_behaves_like 'issues statistics' + end + + context 'with disable_anonymous_search enabled' do + before do + stub_feature_flags(disable_anonymous_search: true) + end + + it 'returns a unprocessable entity 422' do + get api("/issues_statistics"), params: params + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response['message']).to include('User must be authenticated to use search') + end + end + end + end end end diff --git a/spec/requests/api/issues/post_projects_issues_spec.rb b/spec/requests/api/issues/post_projects_issues_spec.rb index 9d3bd26a200..82692366589 100644 --- a/spec/requests/api/issues/post_projects_issues_spec.rb +++ b/spec/requests/api/issues/post_projects_issues_spec.rb @@ -8,15 +8,15 @@ RSpec.describe API::Issues do create(:project, :public, creator_id: user.id, namespace: user.namespace) end - let(:user2) { create(:user) } - let(:non_member) { create(:user) } - let_it_be(:guest) { create(:user) } - let_it_be(:author) { create(:author) } - let_it_be(:assignee) { create(:assignee) } - let(:admin) { create(:user, :admin) } - let(:issue_title) { 'foo' } - let(:issue_description) { 'closed' } - let!(:closed_issue) do + let_it_be(:user2) { create(:user) } + let_it_be(:non_member) { create(:user) } + let_it_be(:guest) { create(:user) } + let_it_be(:author) { create(:author) } + let_it_be(:milestone) { create(:milestone, title: '1.0.0', project: project) } + let_it_be(:assignee) { create(:assignee) } + let_it_be(:admin) { create(:user, :admin) } + + let_it_be(:closed_issue) do create :closed_issue, author: user, assignees: [user], @@ -28,7 +28,7 @@ RSpec.describe API::Issues do closed_at: 1.hour.ago end - let!(:confidential_issue) do + let_it_be(:confidential_issue) do create :issue, :confidential, project: project, @@ -38,7 +38,7 @@ RSpec.describe API::Issues do updated_at: 2.hours.ago end - let!(:issue) do + let_it_be(:issue) do create :issue, author: user, assignees: [user], @@ -46,22 +46,21 @@ RSpec.describe API::Issues do milestone: milestone, created_at: generate(:past_time), updated_at: 1.hour.ago, - title: issue_title, - description: issue_description + title: 'foo', + description: 'closed' end + let_it_be(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) } + let_it_be(:label) do create(:label, title: 'label', color: '#FFAABB', project: project) end let!(:label_link) { create(:label_link, label: label, target: issue) } - let(:milestone) { create(:milestone, title: '1.0.0', project: project) } let_it_be(:empty_milestone) do create(:milestone, title: '2.0.0', project: project) end - let!(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) } - let(:no_milestone_title) { 'None' } let(:any_milestone_title) { 'Any' } @@ -400,16 +399,15 @@ RSpec.describe API::Issues do end context 'when request exceeds the rate limit' do - before do + it 'prevents users from creating more issues' do allow(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(true) - end - it 'prevents users from creating more issues' do post api("/projects/#{project.id}/issues", user), params: { title: 'new issue', labels: 'label, label2', weight: 3, assignee_ids: [user2.id] } - expect(response).to have_gitlab_http_status(:too_many_requests) expect(json_response['message']['error']).to eq('This endpoint has been requested too many times. Try again later.') + + expect(response).to have_gitlab_http_status(:too_many_requests) end end end @@ -517,7 +515,7 @@ RSpec.describe API::Issues do end context 'when using the issue ID instead of iid' do - it 'returns 404 when trying to move an issue' do + it 'returns 404 when trying to move an issue', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/341520' do post api("/projects/#{project.id}/issues/#{issue.id}/move", user), params: { to_project_id: target_project.id } @@ -556,6 +554,114 @@ RSpec.describe API::Issues do end end + describe '/projects/:id/issues/:issue_iid/clone' do + let_it_be(:valid_target_project) { create(:project) } + let_it_be(:invalid_target_project) { create(:project) } + + before_all do + valid_target_project.add_maintainer(user) + end + + context 'when user can admin the issue' do + context 'when the user can admin the target project' do + it 'clones the issue' do + expect do + post_clone_issue(user, issue, valid_target_project) + end.to change { valid_target_project.issues.count }.by(1) + + cloned_issue = Issue.last + + expect(cloned_issue.notes.count).to eq(2) + expect(cloned_issue.notes.pluck(:note)).not_to include(issue.notes.first.note) + expect(response).to have_gitlab_http_status(:created) + expect(json_response['id']).to eq(cloned_issue.id) + expect(json_response['project_id']).to eq(valid_target_project.id) + end + + context 'when target project is the same source project' do + it 'clones the issue' do + expect do + post_clone_issue(user, issue, issue.project) + end.to change { issue.reset.project.issues.count }.by(1) + + cloned_issue = Issue.last + + expect(cloned_issue.notes.count).to eq(2) + expect(cloned_issue.notes.pluck(:note)).not_to include(issue.notes.first.note) + expect(response).to have_gitlab_http_status(:created) + expect(json_response['id']).to eq(cloned_issue.id) + expect(json_response['project_id']).to eq(issue.project.id) + end + end + end + end + + context 'when the user does not have the permission to clone issues' do + it 'returns 400' do + post api("/projects/#{project.id}/issues/#{issue.iid}/clone", user), + params: { to_project_id: invalid_target_project.id } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to eq(s_('CloneIssue|Cannot clone issue due to insufficient permissions!')) + end + end + + context 'when using the issue ID instead of iid' do + it 'returns 404', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/341520' do + post api("/projects/#{project.id}/issues/#{issue.id}/clone", user), + params: { to_project_id: valid_target_project.id } + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Issue Not Found') + end + end + + context 'when issue does not exist' do + it 'returns 404' do + post api("/projects/#{project.id}/issues/12300/clone", user), + params: { to_project_id: valid_target_project.id } + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Issue Not Found') + end + end + + context 'when source project does not exist' do + it 'returns 404' do + post api("/projects/0/issues/#{issue.iid}/clone", user), + params: { to_project_id: valid_target_project.id } + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Project Not Found') + end + end + + context 'when target project does not exist' do + it 'returns 404' do + post api("/projects/#{project.id}/issues/#{issue.iid}/clone", user), + params: { to_project_id: 0 } + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Project Not Found') + end + end + + it 'clones the issue with notes when with_notes is true' do + expect do + post api("/projects/#{project.id}/issues/#{issue.iid}/clone", user), + params: { to_project_id: valid_target_project.id, with_notes: true } + end.to change { valid_target_project.issues.count }.by(1) + + cloned_issue = Issue.last + + expect(cloned_issue.notes.count).to eq(3) + expect(cloned_issue.notes.pluck(:note)).to include(issue.notes.first.note) + expect(response).to have_gitlab_http_status(:created) + expect(json_response['id']).to eq(cloned_issue.id) + expect(json_response['project_id']).to eq(valid_target_project.id) + end + end + describe 'POST :id/issues/:issue_iid/subscribe' do it 'subscribes to an issue' do post api("/projects/#{project.id}/issues/#{issue.iid}/subscribe", user2) @@ -576,7 +682,7 @@ RSpec.describe API::Issues do expect(response).to have_gitlab_http_status(:not_found) end - it 'returns 404 if the issue ID is used instead of the iid' do + it 'returns 404 if the issue ID is used instead of the iid', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/341520' do post api("/projects/#{project.id}/issues/#{issue.id}/subscribe", user) expect(response).to have_gitlab_http_status(:not_found) @@ -609,7 +715,7 @@ RSpec.describe API::Issues do expect(response).to have_gitlab_http_status(:not_found) end - it 'returns 404 if using the issue ID instead of iid' do + it 'returns 404 if using the issue ID instead of iid', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/341520' do post api("/projects/#{project.id}/issues/#{issue.id}/unsubscribe", user) expect(response).to have_gitlab_http_status(:not_found) @@ -621,4 +727,9 @@ RSpec.describe API::Issues do expect(response).to have_gitlab_http_status(:not_found) end end + + def post_clone_issue(current_user, issue, target_project) + post api("/projects/#{issue.project.id}/issues/#{issue.iid}/clone", current_user), + params: { to_project_id: target_project.id } + end end diff --git a/spec/requests/api/maven_packages_spec.rb b/spec/requests/api/maven_packages_spec.rb index 07111dd1d62..5a682ee8532 100644 --- a/spec/requests/api/maven_packages_spec.rb +++ b/spec/requests/api/maven_packages_spec.rb @@ -798,8 +798,6 @@ RSpec.describe API::MavenPackages do end describe 'PUT /api/v4/projects/:id/packages/maven/*path/:file_name' do - include_context 'workhorse headers' - let(:send_rewritten_field) { true } let(:file_upload) { fixture_file_upload('spec/fixtures/packages/maven/my-app-1.0-20180724.124855-1.jar') } @@ -833,6 +831,8 @@ RSpec.describe API::MavenPackages do context 'when params from workhorse are correct' do let(:params) { { file: file_upload } } + subject { upload_file_with_token(params: params) } + context 'file size is too large' do it 'rejects the request' do allow_next_instance_of(UploadedFile) do |uploaded_file| @@ -851,18 +851,20 @@ RSpec.describe API::MavenPackages do expect(response).to have_gitlab_http_status(:bad_request) end - context 'without workhorse header' do - let(:workhorse_headers) { {} } - - subject { upload_file_with_token(params: params) } - - it_behaves_like 'package workhorse uploads' - end + it_behaves_like 'package workhorse uploads' context 'event tracking' do - subject { upload_file_with_token(params: params) } - it_behaves_like 'a package tracking event', described_class.name, 'push_package' + + context 'when the package file fails to be created' do + before do + allow_next_instance_of(::Packages::CreatePackageFileService) do |create_package_file_service| + allow(create_package_file_service).to receive(:execute).and_raise(StandardError) + end + end + + it_behaves_like 'not a package tracking event' + end end it 'creates package and stores package file' do diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 7a587e82683..bdbc73a59d8 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -49,6 +49,12 @@ RSpec.describe API::MergeRequests do expect_successful_response_with_paginated_array end + + it_behaves_like 'issuable anonymous search' do + let(:url) { endpoint_path } + let(:issuable) { merge_request } + let(:result) { [merge_request_merged.id, merge_request_locked.id, merge_request_closed.id, merge_request.id] } + end end context 'when authenticated' do @@ -613,6 +619,12 @@ RSpec.describe API::MergeRequests do ) end + it_behaves_like 'issuable anonymous search' do + let(:url) { '/merge_requests' } + let(:issuable) { merge_request } + let(:result) { [merge_request_merged.id, merge_request_locked.id, merge_request_closed.id, merge_request.id] } + end + it "returns authentication error without any scope" do get api("/merge_requests") diff --git a/spec/requests/api/package_files_spec.rb b/spec/requests/api/package_files_spec.rb index 137ded050c5..eb1f04d193e 100644 --- a/spec/requests/api/package_files_spec.rb +++ b/spec/requests/api/package_files_spec.rb @@ -38,7 +38,7 @@ RSpec.describe API::PackageFiles do expect(response).to have_gitlab_http_status(:not_found) end - it 'returns 404 for a user without access to the project' do + it 'returns 404 for a user without access to the project', :sidekiq_inline do project.team.truncate get api(url, user) diff --git a/spec/requests/api/project_container_repositories_spec.rb b/spec/requests/api/project_container_repositories_spec.rb index 1170a9ba6cb..196b0395ec0 100644 --- a/spec/requests/api/project_container_repositories_spec.rb +++ b/spec/requests/api/project_container_repositories_spec.rb @@ -52,6 +52,7 @@ RSpec.describe API::ProjectContainerRepositories do test_repository stub_container_registry_config(enabled: true) + stub_container_registry_info end shared_context 'using API user' do @@ -105,6 +106,9 @@ RSpec.describe API::ProjectContainerRepositories do it_behaves_like 'rejected container repository access', :guest, :forbidden unless context == 'using job token' it_behaves_like 'rejected container repository access', :anonymous, :not_found it_behaves_like 'a package tracking event', described_class.name, 'list_repositories' + it_behaves_like 'handling network errors with the container registry' do + let(:params) { { tags: true } } + end it_behaves_like 'returns repositories for allowed users', :reporter, 'project' do let(:object) { project } @@ -154,6 +158,7 @@ RSpec.describe API::ProjectContainerRepositories do it_behaves_like 'rejected container repository access', :guest, :forbidden unless context == 'using job token' it_behaves_like 'rejected container repository access', :anonymous, :not_found + it_behaves_like 'handling network errors with the container registry' context 'for reporter' do let(:api_user) { reporter } diff --git a/spec/requests/api/project_export_spec.rb b/spec/requests/api/project_export_spec.rb index 06f4475ef79..b9c458373a8 100644 --- a/spec/requests/api/project_export_spec.rb +++ b/spec/requests/api/project_export_spec.rb @@ -457,4 +457,143 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache do end end end + + describe 'export relations' do + let(:relation) { 'labels' } + let(:download_path) { "/projects/#{project.id}/export_relations/download?relation=#{relation}" } + let(:path) { "/projects/#{project.id}/export_relations" } + + let_it_be(:status_path) { "/projects/#{project.id}/export_relations/status" } + + context 'when user is a maintainer' do + before do + project.add_maintainer(user) + end + + describe 'POST /projects/:id/export_relations' do + it 'accepts the request' do + post api(path, user) + + expect(response).to have_gitlab_http_status(:accepted) + end + + context 'when response is not success' do + it 'returns api error' do + allow_next_instance_of(BulkImports::ExportService) do |service| + allow(service).to receive(:execute).and_return(ServiceResponse.error(message: 'error', http_status: :error)) + end + + post api(path, user) + + expect(response).to have_gitlab_http_status(:error) + end + end + end + + describe 'GET /projects/:id/export_relations/download' do + let_it_be(:export) { create(:bulk_import_export, project: project, relation: 'labels') } + let_it_be(:upload) { create(:bulk_import_export_upload, export: export) } + + context 'when export file exists' do + it 'downloads exported project relation archive' do + upload.update!(export_file: fixture_file_upload('spec/fixtures/bulk_imports/gz/labels.ndjson.gz')) + + get api(download_path, user) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.header['Content-Disposition']).to eq("attachment; filename=\"labels.ndjson.gz\"; filename*=UTF-8''labels.ndjson.gz") + end + end + + context 'when relation is not portable' do + let(:relation) { ::BulkImports::FileTransfer::ProjectConfig.new(project).skipped_relations.first } + + it_behaves_like '400 response' do + let(:request) { get api(download_path, user) } + end + end + + context 'when export file does not exist' do + it 'returns 404' do + allow(upload).to receive(:export_file).and_return(nil) + + get api(download_path, user) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + describe 'GET /projects/:id/export_relations/status' do + it 'returns a list of relation export statuses' do + create(:bulk_import_export, :started, project: project, relation: 'labels') + create(:bulk_import_export, :finished, project: project, relation: 'milestones') + create(:bulk_import_export, :failed, project: project, relation: 'project_badges') + + get api(status_path, user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.pluck('relation')).to contain_exactly('labels', 'milestones', 'project_badges') + expect(json_response.pluck('status')).to contain_exactly(-1, 0, 1) + end + end + + context 'with bulk_import FF disabled' do + before do + stub_feature_flags(bulk_import: false) + end + + describe 'POST /projects/:id/export_relations' do + it_behaves_like '404 response' do + let(:request) { post api(path, user) } + end + end + + describe 'GET /projects/:id/export_relations/download' do + let_it_be(:export) { create(:bulk_import_export, project: project, relation: 'labels') } + let_it_be(:upload) { create(:bulk_import_export_upload, export: export) } + + before do + upload.update!(export_file: fixture_file_upload('spec/fixtures/bulk_imports/gz/labels.ndjson.gz')) + end + + it_behaves_like '404 response' do + let(:request) { post api(path, user) } + end + end + + describe 'GET /projects/:id/export_relations/status' do + it_behaves_like '404 response' do + let(:request) { get api(status_path, user) } + end + end + end + end + + context 'when user is a developer' do + let_it_be(:developer) { create(:user) } + + before do + project.add_developer(developer) + end + + describe 'POST /projects/:id/export_relations' do + it_behaves_like '403 response' do + let(:request) { post api(path, developer) } + end + end + + describe 'GET /projects/:id/export_relations/download' do + it_behaves_like '403 response' do + let(:request) { get api(download_path, developer) } + end + end + + describe 'GET /projects/:id/export_relations/status' do + it_behaves_like '403 response' do + let(:request) { get api(status_path, developer) } + end + end + end + end end diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index be8a6c7bdcf..b5d3dcee804 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -2591,7 +2591,7 @@ RSpec.describe API::Projects do end end - it_behaves_like 'storing arguments in the application context' do + it_behaves_like 'storing arguments in the application context for the API' do let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project, :public) } let(:expected_params) { { user: user.username, project: project.full_path } } @@ -2684,26 +2684,9 @@ RSpec.describe API::Projects do context 'when authenticated' do context 'valid request' do - context 'when sort_by_project_authorizations_user_id FF is off' do - before do - stub_feature_flags(sort_by_project_users_by_project_authorizations_user_id: false) - end - - it_behaves_like 'project users response' do - let(:project) { project4 } - let(:current_user) { user4 } - end - end - - context 'when sort_by_project_authorizations_user_id FF is on' do - before do - stub_feature_flags(sort_by_project_users_by_project_authorizations_user_id: true) - end - - it_behaves_like 'project users response' do - let(:project) { project4 } - let(:current_user) { user4 } - end + it_behaves_like 'project users response' do + let(:project) { project4 } + let(:current_user) { user4 } end end diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb index a576e1ab1ee..f05f125c974 100644 --- a/spec/requests/api/repositories_spec.rb +++ b/spec/requests/api/repositories_spec.rb @@ -305,6 +305,18 @@ RSpec.describe API::Repositories do end end + it 'returns only a part of the repository with path set' do + path = 'bar' + get api("#{route}?path=#{path}", current_user) + + expect(response).to have_gitlab_http_status(:ok) + + type, params = workhorse_send_data + + expect(type).to eq('git-archive') + expect(params['ArchivePath']).to match(/#{project.path}\-[^\.]+\-#{path}\.tar.gz/) + end + it 'rate limits user when thresholds hit' do allow(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(true) diff --git a/spec/requests/api/services_spec.rb b/spec/requests/api/services_spec.rb deleted file mode 100644 index e550132e776..00000000000 --- a/spec/requests/api/services_spec.rb +++ /dev/null @@ -1,361 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe API::Services do - let_it_be(:user) { create(:user) } - let_it_be(:user2) { create(:user) } - - let_it_be(:project, reload: true) do - create(:project, creator_id: user.id, namespace: user.namespace) - end - - describe "GET /projects/:id/services" do - it 'returns authentication error when unauthenticated' do - get api("/projects/#{project.id}/services") - - expect(response).to have_gitlab_http_status(:unauthorized) - end - - it "returns error when authenticated but user is not a project owner" do - project.add_developer(user2) - get api("/projects/#{project.id}/services", user2) - - expect(response).to have_gitlab_http_status(:forbidden) - end - - context 'with integrations' do - let!(:active_integration) { create(:emails_on_push_integration, project: project, active: true) } - let!(:integration) { create(:custom_issue_tracker_integration, project: project, active: false) } - - it "returns a list of all active integrations" do - get api("/projects/#{project.id}/services", user) - - aggregate_failures 'expect successful response with all active integrations' do - expect(response).to have_gitlab_http_status(:ok) - expect(json_response).to be_an Array - expect(json_response.count).to eq(1) - expect(json_response.first['slug']).to eq('emails-on-push') - expect(response).to match_response_schema('public_api/v4/services') - end - end - end - end - - Integration.available_integration_names.each do |integration| - describe "PUT /projects/:id/services/#{integration.dasherize}" do - include_context integration - - it "updates #{integration} settings" do - put api("/projects/#{project.id}/services/#{dashed_integration}", user), params: integration_attrs - - expect(response).to have_gitlab_http_status(:ok) - - current_integration = project.integrations.first - events = current_integration.event_names.empty? ? ["foo"].freeze : current_integration.event_names - query_strings = [] - events.each do |event| - query_strings << "#{event}=#{!current_integration[event]}" - end - query_strings = query_strings.join('&') - - put api("/projects/#{project.id}/services/#{dashed_integration}?#{query_strings}", user), params: integration_attrs - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['slug']).to eq(dashed_integration) - events.each do |event| - next if event == "foo" - - expect(project.integrations.first[event]).not_to eq(current_integration[event]), - "expected #{!current_integration[event]} for event #{event} for service #{current_integration.title}, got #{current_integration[event]}" - end - end - - it "returns if required fields missing" do - required_attributes = integration_attrs_list.select do |attr| - integration_klass.validators_on(attr).any? do |v| - v.instance_of?(ActiveRecord::Validations::PresenceValidator) && - # exclude presence validators with conditional since those are not really required - ![:if, :unless].any? { |cond| v.options.include?(cond) } - end - end - - if required_attributes.empty? - expected_code = :ok - else - integration_attrs.delete(required_attributes.sample) - expected_code = :bad_request - end - - put api("/projects/#{project.id}/services/#{dashed_integration}", user), params: integration_attrs - - expect(response).to have_gitlab_http_status(expected_code) - end - end - - describe "DELETE /projects/:id/services/#{integration.dasherize}" do - include_context integration - - before do - initialize_integration(integration) - end - - it "deletes #{integration}" do - delete api("/projects/#{project.id}/services/#{dashed_integration}", user) - - expect(response).to have_gitlab_http_status(:no_content) - project.send(integration_method).reload - expect(project.send(integration_method).activated?).to be_falsey - end - end - - describe "GET /projects/:id/services/#{integration.dasherize}" do - include_context integration - - let!(:initialized_integration) { initialize_integration(integration, active: true) } - - let_it_be(:project2) do - create(:project, creator_id: user.id, namespace: user.namespace) - end - - def deactive_integration! - return initialized_integration.update!(active: false) unless initialized_integration.is_a?(::Integrations::Prometheus) - - # Integrations::Prometheus sets `#active` itself within a `before_save`: - initialized_integration.manual_configuration = false - initialized_integration.save! - end - - it 'returns authentication error when unauthenticated' do - get api("/projects/#{project.id}/services/#{dashed_integration}") - expect(response).to have_gitlab_http_status(:unauthorized) - end - - it "returns all properties of active service #{integration}" do - get api("/projects/#{project.id}/services/#{dashed_integration}", user) - - expect(initialized_integration).to be_active - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['properties'].keys).to match_array(integration_instance.api_field_names) - end - - it "returns all properties of inactive integration #{integration}" do - deactive_integration! - - get api("/projects/#{project.id}/services/#{dashed_integration}", user) - - expect(initialized_integration).not_to be_active - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['properties'].keys).to match_array(integration_instance.api_field_names) - end - - it "returns not found if integration does not exist" do - get api("/projects/#{project2.id}/services/#{dashed_integration}", user) - - expect(response).to have_gitlab_http_status(:not_found) - expect(json_response['message']).to eq('404 Service Not Found') - end - - it "returns not found if service exists but is in `Project#disabled_integrations`" do - expect_next_found_instance_of(Project) do |project| - expect(project).to receive(:disabled_integrations).at_least(:once).and_return([integration]) - end - - get api("/projects/#{project.id}/services/#{dashed_integration}", user) - - expect(response).to have_gitlab_http_status(:not_found) - expect(json_response['message']).to eq('404 Service Not Found') - end - - it "returns error when authenticated but not a project owner" do - project.add_developer(user2) - get api("/projects/#{project.id}/services/#{dashed_integration}", user2) - - expect(response).to have_gitlab_http_status(:forbidden) - end - end - end - - describe 'POST /projects/:id/services/:slug/trigger' do - describe 'Mattermost integration' do - let(:integration_name) { 'mattermost_slash_commands' } - - context 'when no integration is available' do - it 'returns a not found message' do - post api("/projects/#{project.id}/services/idonotexist/trigger") - - expect(response).to have_gitlab_http_status(:not_found) - expect(json_response["error"]).to eq("404 Not Found") - end - end - - context 'when the integration exists' do - let(:params) { { token: 'token' } } - - context 'when the integration is not active' do - before do - project.create_mattermost_slash_commands_integration( - active: false, - properties: params - ) - end - - it 'when the integration is inactive' do - post api("/projects/#{project.id}/services/#{integration_name}/trigger"), params: params - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'when the integration is active' do - before do - project.create_mattermost_slash_commands_integration( - active: true, - properties: params - ) - end - - it 'returns status 200' do - post api("/projects/#{project.id}/services/#{integration_name}/trigger"), params: params - - expect(response).to have_gitlab_http_status(:ok) - end - end - - context 'when the project can not be found' do - it 'returns a generic 404' do - post api("/projects/404/services/#{integration_name}/trigger"), params: params - - expect(response).to have_gitlab_http_status(:not_found) - expect(json_response["message"]).to eq("404 Service Not Found") - end - end - end - end - - describe 'Slack Integration' do - let(:integration_name) { 'slack_slash_commands' } - - before do - project.create_slack_slash_commands_integration( - active: true, - properties: { token: 'token' } - ) - end - - it 'returns status 200' do - post api("/projects/#{project.id}/services/#{integration_name}/trigger"), params: { token: 'token', text: 'help' } - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['response_type']).to eq("ephemeral") - end - end - end - - describe 'Mattermost integration' do - let(:integration_name) { 'mattermost' } - let(:params) do - { webhook: 'https://hook.example.com', username: 'username' } - end - - before do - project.create_mattermost_integration( - active: true, - properties: params - ) - end - - it 'accepts a username for update' do - put api("/projects/#{project.id}/services/#{integration_name}", user), params: params.merge(username: 'new_username') - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['properties']['username']).to eq('new_username') - end - end - - describe 'Microsoft Teams integration' do - let(:integration_name) { 'microsoft-teams' } - let(:params) do - { - webhook: 'https://hook.example.com', - branches_to_be_notified: 'default', - notify_only_broken_pipelines: false - } - end - - before do - project.create_microsoft_teams_integration( - active: true, - properties: params - ) - end - - it 'accepts branches_to_be_notified for update' do - put api("/projects/#{project.id}/services/#{integration_name}", user), - params: params.merge(branches_to_be_notified: 'all') - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['properties']['branches_to_be_notified']).to eq('all') - end - - it 'accepts notify_only_broken_pipelines for update' do - put api("/projects/#{project.id}/services/#{integration_name}", user), - params: params.merge(notify_only_broken_pipelines: true) - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['properties']['notify_only_broken_pipelines']).to eq(true) - end - end - - describe 'Hangouts Chat integration' do - let(:integration_name) { 'hangouts-chat' } - let(:params) do - { - webhook: 'https://hook.example.com', - branches_to_be_notified: 'default' - } - end - - before do - project.create_hangouts_chat_integration( - active: true, - properties: params - ) - end - - it 'accepts branches_to_be_notified for update', :aggregate_failures do - put api("/projects/#{project.id}/services/#{integration_name}", user), params: params.merge(branches_to_be_notified: 'all') - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['properties']['branches_to_be_notified']).to eq('all') - end - - it 'only requires the webhook param' do - put api("/projects/#{project.id}/services/#{integration_name}", user), params: { webhook: 'https://hook.example.com' } - - expect(response).to have_gitlab_http_status(:ok) - end - end - - describe 'Pipelines Email Integration' do - let(:integration_name) { 'pipelines-email' } - - context 'notify_only_broken_pipelines property was saved as a string' do - before do - project.create_pipelines_email_integration( - active: false, - properties: { - "notify_only_broken_pipelines": "true", - "branches_to_be_notified": "default" - } - ) - end - - it 'returns boolean values for notify_only_broken_pipelines' do - get api("/projects/#{project.id}/services/#{integration_name}", user) - - expect(json_response['properties']['notify_only_broken_pipelines']).to eq(true) - end - end - end -end diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index f5d261ba4c6..423e19c3971 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -48,6 +48,7 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do expect(json_response['admin_mode']).to be(false) expect(json_response['whats_new_variant']).to eq('all_tiers') expect(json_response['user_deactivation_emails_enabled']).to be(true) + expect(json_response['suggest_pipeline_enabled']).to be(true) end end @@ -135,7 +136,8 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do wiki_page_max_content_bytes: 12345, personal_access_token_prefix: "GL-", user_deactivation_emails_enabled: false, - admin_mode: true + admin_mode: true, + suggest_pipeline_enabled: false } expect(response).to have_gitlab_http_status(:ok) @@ -187,6 +189,7 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do expect(json_response['personal_access_token_prefix']).to eq("GL-") expect(json_response['admin_mode']).to be(true) expect(json_response['user_deactivation_emails_enabled']).to be(false) + expect(json_response['suggest_pipeline_enabled']).to be(false) end end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index ee1911b0a26..fb01845b63a 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -1457,10 +1457,20 @@ RSpec.describe API::Users do describe "PUT /user/:id/credit_card_validation" do let(:credit_card_validated_time) { Time.utc(2020, 1, 1) } + let(:expiration_year) { Date.today.year + 10 } + let(:params) do + { + credit_card_validated_at: credit_card_validated_time, + credit_card_expiration_year: expiration_year, + credit_card_expiration_month: 1, + credit_card_holder_name: 'John Smith', + credit_card_mask_number: '1111' + } + end context 'when unauthenticated' do it 'returns authentication error' do - put api("/user/#{user.id}/credit_card_validation"), params: { credit_card_validated_at: credit_card_validated_time } + put api("/user/#{user.id}/credit_card_validation"), params: {} expect(response).to have_gitlab_http_status(:unauthorized) end @@ -1468,7 +1478,7 @@ RSpec.describe API::Users do context 'when authenticated as non-admin' do it "does not allow updating user's credit card validation", :aggregate_failures do - put api("/user/#{user.id}/credit_card_validation", user), params: { credit_card_validated_at: credit_card_validated_time } + put api("/user/#{user.id}/credit_card_validation", user), params: params expect(response).to have_gitlab_http_status(:forbidden) end @@ -1476,10 +1486,17 @@ RSpec.describe API::Users do context 'when authenticated as admin' do it "updates user's credit card validation", :aggregate_failures do - put api("/user/#{user.id}/credit_card_validation", admin), params: { credit_card_validated_at: credit_card_validated_time } + put api("/user/#{user.id}/credit_card_validation", admin), params: params + + user.reload expect(response).to have_gitlab_http_status(:ok) - expect(user.reload.credit_card_validated_at).to eq(credit_card_validated_time) + expect(user.credit_card_validation).to have_attributes( + credit_card_validated_at: credit_card_validated_time, + expiration_date: Date.new(expiration_year, 1, 31), + last_digits: 1111, + holder_name: 'John Smith' + ) end it "returns 400 error if credit_card_validated_at is missing" do @@ -1489,7 +1506,7 @@ RSpec.describe API::Users do end it 'returns 404 error if user not found' do - put api("/user/#{non_existing_record_id}/credit_card_validation", admin), params: { credit_card_validated_at: credit_card_validated_time } + put api("/user/#{non_existing_record_id}/credit_card_validation", admin), params: params expect(response).to have_gitlab_http_status(:not_found) expect(json_response['message']).to eq('404 User Not Found') diff --git a/spec/requests/groups/registry/repositories_controller_spec.rb b/spec/requests/groups/registry/repositories_controller_spec.rb index 89cbd3e4100..0699f48c2be 100644 --- a/spec/requests/groups/registry/repositories_controller_spec.rb +++ b/spec/requests/groups/registry/repositories_controller_spec.rb @@ -9,6 +9,7 @@ RSpec.describe Groups::Registry::RepositoriesController do before do stub_container_registry_config(enabled: true) stub_container_registry_tags(repository: :any, tags: []) + stub_container_registry_info group.add_reporter(user) login_as(user) end diff --git a/spec/requests/import/url_controller_spec.rb b/spec/requests/import/url_controller_spec.rb new file mode 100644 index 00000000000..63af5e8b469 --- /dev/null +++ b/spec/requests/import/url_controller_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Import::UrlController do + let_it_be(:user) { create(:user) } + + before do + login_as(user) + end + + describe 'POST #validate' do + it 'reports success when service reports success status' do + allow_next_instance_of(Import::ValidateRemoteGitEndpointService) do |validate_endpoint_service| + allow(validate_endpoint_service).to receive(:execute).and_return(ServiceResponse.success) + end + + post import_url_validate_path, params: { url: 'https://fake.repo' } + + expect(json_response).to eq({ 'success' => true }) + end + + it 'exposes error message when service reports error' do + expect_next_instance_of(Import::ValidateRemoteGitEndpointService) do |validate_endpoint_service| + expect(validate_endpoint_service).to receive(:execute).and_return(ServiceResponse.error(message: 'foobar')) + end + + post import_url_validate_path, params: { url: 'https://fake.repo' } + + expect(json_response).to eq({ 'success' => false, 'message' => 'foobar' }) + end + + context 'with an anonymous user' do + before do + sign_out(user) + end + + it 'redirects to sign-in page' do + post import_url_validate_path + + expect(response).to redirect_to(new_user_session_path) + end + end + end +end diff --git a/spec/requests/projects/cluster_agents_controller_spec.rb b/spec/requests/projects/cluster_agents_controller_spec.rb new file mode 100644 index 00000000000..e4c4f537699 --- /dev/null +++ b/spec/requests/projects/cluster_agents_controller_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::ClusterAgentsController do + let_it_be(:cluster_agent) { create(:cluster_agent) } + + let(:project) { cluster_agent.project } + + describe 'GET #show' do + subject { get project_cluster_agent_path(project, cluster_agent.name) } + + context 'when user is unauthorized' do + let_it_be(:user) { create(:user) } + + before do + project.add_developer(user) + sign_in(user) + subject + end + + it 'shows 404' do + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when user is authorized' do + let(:user) { project.creator } + + before do + sign_in(user) + subject + end + + it 'renders content' do + expect(response).to be_successful + end + end + end +end diff --git a/spec/requests/projects/google_cloud_controller_spec.rb b/spec/requests/projects/google_cloud_controller_spec.rb new file mode 100644 index 00000000000..3b43f0d1dfb --- /dev/null +++ b/spec/requests/projects/google_cloud_controller_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::GoogleCloudController do + let_it_be(:project) { create(:project, :public) } + + describe 'GET index' do + let_it_be(:url) { "#{project_google_cloud_index_path(project)}" } + + let(:subject) { get url } + + context 'when user is authorized' do + let(:user) { project.creator } + + before do + sign_in(user) + subject + end + + it 'renders content' do + expect(response).to be_successful + end + end + + context 'when user is unauthorized' do + let(:user) { create(:user) } + + before do + project.add_guest(user) + sign_in(user) + subject + end + + it 'shows 404' do + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when no user is present' do + before do + subject + end + + it 'shows 404' do + expect(response).to have_gitlab_http_status(:not_found) + end + end + end +end diff --git a/spec/requests/projects/merge_requests_discussions_spec.rb b/spec/requests/projects/merge_requests_discussions_spec.rb index 8057a091bba..4921a43ab8b 100644 --- a/spec/requests/projects/merge_requests_discussions_spec.rb +++ b/spec/requests/projects/merge_requests_discussions_spec.rb @@ -5,11 +5,13 @@ require 'spec_helper' RSpec.describe 'merge requests discussions' do # Further tests can be found at merge_requests_controller_spec.rb describe 'GET /:namespace/:project/-/merge_requests/:iid/discussions' do - let(:project) { create(:project, :repository) } - let(:user) { project.owner } + let(:project) { create(:project, :repository, :public) } + let(:owner) { project.owner } + let(:user) { create(:user) } let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) } before do + project.add_maintainer(owner) project.add_developer(user) login_as(user) end @@ -232,7 +234,7 @@ RSpec.describe 'merge requests discussions' do context 'when author role changes' do before do - Members::UpdateService.new(user, access_level: Gitlab::Access::GUEST).execute(author_membership) + Members::UpdateService.new(owner, access_level: Gitlab::Access::GUEST).execute(author_membership) end it_behaves_like 'cache miss' do @@ -240,9 +242,9 @@ RSpec.describe 'merge requests discussions' do end end - context 'when merge_request_discussion_cache is disabled' do + context 'when current_user role changes' do before do - stub_feature_flags(merge_request_discussion_cache: false) + Members::UpdateService.new(owner, access_level: Gitlab::Access::GUEST).execute(project.project_member(user)) end it_behaves_like 'cache miss' do diff --git a/spec/requests/rack_attack_global_spec.rb b/spec/requests/rack_attack_global_spec.rb index be942f6ae86..35ce942ed7e 100644 --- a/spec/requests/rack_attack_global_spec.rb +++ b/spec/requests/rack_attack_global_spec.rb @@ -30,7 +30,11 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac throttle_unauthenticated_files_api_requests_per_period: 100, throttle_unauthenticated_files_api_period_in_seconds: 1, throttle_authenticated_files_api_requests_per_period: 100, - throttle_authenticated_files_api_period_in_seconds: 1 + throttle_authenticated_files_api_period_in_seconds: 1, + throttle_unauthenticated_deprecated_api_requests_per_period: 100, + throttle_unauthenticated_deprecated_api_period_in_seconds: 1, + throttle_authenticated_deprecated_api_requests_per_period: 100, + throttle_authenticated_deprecated_api_period_in_seconds: 1 } end @@ -479,6 +483,67 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac end end + describe 'dependency proxy' do + include DependencyProxyHelpers + + let_it_be_with_reload(:group) { create(:group) } + let_it_be_with_reload(:other_group) { create(:group) } + let_it_be(:user) { create(:user) } + let_it_be(:other_user) { create(:user) } + + let(:throttle_setting_prefix) { 'throttle_authenticated_web' } + let(:jwt_token) { build_jwt(user) } + let(:other_jwt_token) { build_jwt(other_user) } + let(:request_args) { [path, headers: jwt_token_authorization_headers(jwt_token)] } + let(:other_user_request_args) { [other_path, headers: jwt_token_authorization_headers(other_jwt_token)] } + + before do + group.add_owner(user) + group.create_dependency_proxy_setting!(enabled: true) + other_group.add_owner(other_user) + other_group.create_dependency_proxy_setting!(enabled: true) + + allow(Gitlab.config.dependency_proxy) + .to receive(:enabled).and_return(true) + token_response = { status: :success, token: 'abcd1234' } + allow_next_instance_of(DependencyProxy::RequestTokenService) do |instance| + allow(instance).to receive(:execute).and_return(token_response) + end + end + + context 'getting a manifest' do + let_it_be(:manifest) { create(:dependency_proxy_manifest) } + + let(:path) { "/v2/#{group.path}/dependency_proxy/containers/alpine/manifests/latest" } + let(:other_path) { "/v2/#{other_group.path}/dependency_proxy/containers/alpine/manifests/latest" } + let(:pull_response) { { status: :success, manifest: manifest, from_cache: false } } + + before do + allow_next_instance_of(DependencyProxy::FindOrCreateManifestService) do |instance| + allow(instance).to receive(:execute).and_return(pull_response) + end + end + + it_behaves_like 'rate-limited token-authenticated requests' + end + + context 'getting a blob' do + let_it_be(:blob) { create(:dependency_proxy_blob) } + + let(:path) { "/v2/#{group.path}/dependency_proxy/containers/alpine/blobs/sha256:a0d0a0d46f8b52473982a3c466318f479767577551a53ffc9074c9fa7035982e" } + let(:other_path) { "/v2/#{other_group.path}/dependency_proxy/containers/alpine/blobs/sha256:a0d0a0d46f8b52473982a3c466318f479767577551a53ffc9074c9fa7035982e" } + let(:blob_response) { { status: :success, blob: blob, from_cache: false } } + + before do + allow_next_instance_of(DependencyProxy::FindOrCreateBlobService) do |instance| + allow(instance).to receive(:execute).and_return(blob_response) + end + end + + it_behaves_like 'rate-limited token-authenticated requests' + end + end + describe 'authenticated git lfs requests', :api do let_it_be(:project) { create(:project, :internal) } let_it_be(:user) { create(:user) } @@ -790,6 +855,213 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac end end + describe 'Deprecated API', :api do + let_it_be(:group) { create(:group, :public) } + + let(:request_method) { 'GET' } + let(:path) { "/groups/#{group.id}" } + let(:params) { {} } + + context 'unauthenticated' do + let(:throttle_setting_prefix) { 'throttle_unauthenticated_deprecated_api' } + + def do_request + get(api(path), params: params) + end + + before do + settings_to_set[:throttle_unauthenticated_deprecated_api_requests_per_period] = requests_per_period + settings_to_set[:throttle_unauthenticated_deprecated_api_period_in_seconds] = period_in_seconds + end + + context 'when unauthenticated deprecated api throttle is disabled' do + before do + settings_to_set[:throttle_unauthenticated_deprecated_api_enabled] = false + stub_application_setting(settings_to_set) + end + + it 'allows requests over the rate limit' do + (1 + requests_per_period).times do + do_request + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when unauthenticated api throttle is enabled' do + before do + settings_to_set[:throttle_unauthenticated_api_requests_per_period] = requests_per_period + settings_to_set[:throttle_unauthenticated_api_period_in_seconds] = period_in_seconds + settings_to_set[:throttle_unauthenticated_api_enabled] = true + stub_application_setting(settings_to_set) + end + + it 'rejects requests over the unauthenticated api rate limit' do + requests_per_period.times do + do_request + expect(response).to have_gitlab_http_status(:ok) + end + + expect_rejection { do_request } + end + end + + context 'when unauthenticated web throttle is enabled' do + before do + settings_to_set[:throttle_unauthenticated_web_requests_per_period] = requests_per_period + settings_to_set[:throttle_unauthenticated_web_period_in_seconds] = period_in_seconds + settings_to_set[:throttle_unauthenticated_web_enabled] = true + stub_application_setting(settings_to_set) + end + + it 'ignores unauthenticated web throttle' do + (1 + requests_per_period).times do + do_request + expect(response).to have_gitlab_http_status(:ok) + end + end + end + end + + context 'when unauthenticated deprecated api throttle is enabled' do + before do + settings_to_set[:throttle_unauthenticated_deprecated_api_requests_per_period] = requests_per_period # 1 + settings_to_set[:throttle_unauthenticated_deprecated_api_period_in_seconds] = period_in_seconds # 10_000 + settings_to_set[:throttle_unauthenticated_deprecated_api_enabled] = true + stub_application_setting(settings_to_set) + end + + context 'when group endpoint is given with_project=false' do + let(:params) { { with_projects: false } } + + it 'permits requests over the rate limit' do + (1 + requests_per_period).times do + do_request + expect(response).to have_gitlab_http_status(:ok) + end + end + end + + it 'rejects requests over the rate limit' do + requests_per_period.times do + do_request + expect(response).to have_gitlab_http_status(:ok) + end + + expect_rejection { do_request } + end + + context 'when unauthenticated api throttle is lower' do + before do + settings_to_set[:throttle_unauthenticated_api_requests_per_period] = 0 + settings_to_set[:throttle_unauthenticated_api_period_in_seconds] = period_in_seconds + settings_to_set[:throttle_unauthenticated_api_enabled] = true + stub_application_setting(settings_to_set) + end + + it 'ignores unauthenticated api throttle' do + requests_per_period.times do + do_request + expect(response).to have_gitlab_http_status(:ok) + end + + expect_rejection { do_request } + end + end + + it_behaves_like 'tracking when dry-run mode is set' do + let(:throttle_name) { 'throttle_unauthenticated_deprecated_api' } + end + end + end + + context 'authenticated' do + let_it_be(:user) { create(:user) } + let_it_be(:member) { group.add_owner(user) } + let_it_be(:token) { create(:personal_access_token, user: user) } + let_it_be(:other_user) { create(:user) } + let_it_be(:other_user_token) { create(:personal_access_token, user: other_user) } + + let(:throttle_setting_prefix) { 'throttle_authenticated_deprecated_api' } + + before do + stub_application_setting(settings_to_set) + end + + context 'with the token in the query string' do + let(:request_args) { [api(path, personal_access_token: token), {}] } + let(:other_user_request_args) { [api(path, personal_access_token: other_user_token), {}] } + + it_behaves_like 'rate-limited token-authenticated requests' + end + + context 'with the token in the headers' do + let(:request_args) { api_get_args_with_token_headers(path, personal_access_token_headers(token)) } + let(:other_user_request_args) { api_get_args_with_token_headers(path, personal_access_token_headers(other_user_token)) } + + it_behaves_like 'rate-limited token-authenticated requests' + end + + context 'precedence over authenticated api throttle' do + before do + settings_to_set[:throttle_authenticated_deprecated_api_requests_per_period] = requests_per_period + settings_to_set[:throttle_authenticated_deprecated_api_period_in_seconds] = period_in_seconds + end + + def do_request + get(api(path, personal_access_token: token), params: params) + end + + context 'when authenticated deprecated api throttle is enabled' do + before do + settings_to_set[:throttle_authenticated_deprecated_api_enabled] = true + end + + context 'when authenticated api throttle is lower' do + before do + settings_to_set[:throttle_authenticated_api_requests_per_period] = 0 + settings_to_set[:throttle_authenticated_api_period_in_seconds] = period_in_seconds + settings_to_set[:throttle_authenticated_api_enabled] = true + stub_application_setting(settings_to_set) + end + + it 'ignores authenticated api throttle' do + requests_per_period.times do + do_request + expect(response).to have_gitlab_http_status(:ok) + end + + expect_rejection { do_request } + end + end + end + + context 'when authenticated deprecated api throttle is disabled' do + before do + settings_to_set[:throttle_authenticated_deprecated_api_enabled] = false + end + + context 'when authenticated api throttle is enabled' do + before do + settings_to_set[:throttle_authenticated_api_requests_per_period] = requests_per_period + settings_to_set[:throttle_authenticated_api_period_in_seconds] = period_in_seconds + settings_to_set[:throttle_authenticated_api_enabled] = true + stub_application_setting(settings_to_set) + end + + it 'rejects requests over the authenticated api rate limit' do + requests_per_period.times do + do_request + expect(response).to have_gitlab_http_status(:ok) + end + + expect_rejection { do_request } + end + end + end + end + end + end + describe 'throttle bypass header' do let(:headers) { {} } let(:bypass_header) { 'gitlab-bypass-rate-limiting' } diff --git a/spec/routing/admin/serverless/domains_controller_routing_spec.rb b/spec/routing/admin/serverless/domains_controller_routing_spec.rb deleted file mode 100644 index 60b60809f4d..00000000000 --- a/spec/routing/admin/serverless/domains_controller_routing_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Admin::Serverless::DomainsController do - it 'routes to #index' do - expect(get: '/admin/serverless/domains').to route_to('admin/serverless/domains#index') - end - - it 'routes to #create' do - expect(post: '/admin/serverless/domains/').to route_to('admin/serverless/domains#create') - end - - it 'routes to #update' do - expect(put: '/admin/serverless/domains/1').to route_to(controller: 'admin/serverless/domains', action: 'update', id: '1') - expect(patch: '/admin/serverless/domains/1').to route_to(controller: 'admin/serverless/domains', action: 'update', id: '1') - end - - it 'routes #verify' do - expect(post: '/admin/serverless/domains/1/verify').to route_to(controller: 'admin/serverless/domains', action: 'verify', id: '1') - end -end diff --git a/spec/serializers/member_entity_spec.rb b/spec/serializers/member_entity_spec.rb index dc7aa4611f2..370fa14b1e8 100644 --- a/spec/serializers/member_entity_spec.rb +++ b/spec/serializers/member_entity_spec.rb @@ -39,6 +39,10 @@ RSpec.describe MemberEntity do expect(entity_hash[:invite][:can_resend]).to be(true) end + + it 'exposes `invite.user_state` as empty string' do + expect(entity_hash[:invite][:user_state]).to eq('') + end end shared_examples 'is_direct_member' do @@ -59,6 +63,12 @@ RSpec.describe MemberEntity do end end + shared_examples 'user state is blocked_pending_approval' do + it 'displays proper user state' do + expect(entity_hash[:invite][:user_state]).to eq('blocked_pending_approval') + end + end + context 'group member' do let(:group) { create(:group) } let(:source) { group } @@ -79,6 +89,14 @@ RSpec.describe MemberEntity do it_behaves_like 'is_direct_member' end + + context 'new member user state is blocked_pending_approval' do + let(:user) { create(:user, :blocked_pending_approval) } + let(:group_member) { create(:group_member, :invited, group: group, invite_email: user.email) } + let(:member) { GroupMemberPresenter.new(GroupMember.with_invited_user_state.find(group_member.id), current_user: current_user) } + + it_behaves_like 'user state is blocked_pending_approval' + end end context 'project member' do @@ -102,5 +120,13 @@ RSpec.describe MemberEntity do it_behaves_like 'is_direct_member' end + + context 'new members user state is blocked_pending_approval' do + let(:user) { create(:user, :blocked_pending_approval) } + let(:project_member) { create(:project_member, :invited, project: project, invite_email: user.email) } + let(:member) { ProjectMemberPresenter.new(ProjectMember.with_invited_user_state.find(project_member.id), current_user: current_user) } + + it_behaves_like 'user state is blocked_pending_approval' + end end end diff --git a/spec/serializers/merge_request_metrics_helper_spec.rb b/spec/serializers/merge_request_metrics_helper_spec.rb new file mode 100644 index 00000000000..8f683df1faa --- /dev/null +++ b/spec/serializers/merge_request_metrics_helper_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequestMetricsHelper do + let_it_be(:user) { create(:user) } + + let(:merge_request) { create(:merge_request) } + let(:helper) { Class.new.include(described_class).new } + + describe '#build_metrics' do + subject do + helper.build_metrics(merge_request) + end + + shared_examples 'does not rebuild the metrics' do + it 'does not call the merge request metrics class' do + expect(MergeRequest::Metrics).not_to receive(:new) + + subject + end + + it 'returns the metrics for the given merge request' do + expect(subject).to be_kind_of(MergeRequest::Metrics) + expect(subject[:merge_request_id]).to eq(merge_request.id) + end + end + + context 'when closed and metrics exists' do + before do + merge_request.close! + merge_request.metrics.update!(latest_closed_by: user) + end + + include_examples 'does not rebuild the metrics' + end + + context 'when merged and metrics exists' do + before do + merge_request.mark_as_merged! + merge_request.metrics.update!(merged_by: user) + end + + include_examples 'does not rebuild the metrics' + end + + context 'when merged and metrics do not exists' do + before do + merge_request.mark_as_merged! + merge_request.metrics.destroy! + merge_request.reload + end + + it 'rebuilds the merge request metrics' do + closed_event = merge_request.closed_event + merge_event = merge_request.merge_event + + expect(MergeRequest::Metrics).to receive(:new) + .with(latest_closed_at: closed_event&.updated_at, + latest_closed_by: closed_event&.author, + merged_at: merge_event&.updated_at, + merged_by: merge_event&.author) + .and_call_original + + subject + end + end + end +end diff --git a/spec/serializers/merge_request_poll_cached_widget_entity_spec.rb b/spec/serializers/merge_request_poll_cached_widget_entity_spec.rb index 5f4b734fcea..ecc93219b53 100644 --- a/spec/serializers/merge_request_poll_cached_widget_entity_spec.rb +++ b/spec/serializers/merge_request_poll_cached_widget_entity_spec.rb @@ -275,7 +275,7 @@ RSpec.describe MergeRequestPollCachedWidgetEntity do expect(subject[:merge_pipeline]).to be_nil end - context 'when is merged' do + context 'when is merged', :sidekiq_inline do let(:resource) { create(:merged_merge_request, source_project: project, merge_commit_sha: project.commit.id) } let(:pipeline) { create(:ci_empty_pipeline, project: project, ref: resource.target_branch, sha: resource.merge_commit_sha) } diff --git a/spec/serializers/merge_request_widget_entity_spec.rb b/spec/serializers/merge_request_widget_entity_spec.rb index 35846b0d4ea..fcfdbfc0967 100644 --- a/spec/serializers/merge_request_widget_entity_spec.rb +++ b/spec/serializers/merge_request_widget_entity_spec.rb @@ -283,28 +283,6 @@ RSpec.describe MergeRequestWidgetEntity do it 'provides a valid value for suggest pipeline feature id' do expect(subject[:suggest_pipeline_feature_id]).to eq described_class::SUGGEST_PIPELINE end - - it 'provides a valid value for if it is dismissed' do - expect(subject[:is_dismissed_suggest_pipeline]).to be(false) - end - - context 'when the suggest pipeline has been dismissed' do - before do - create(:user_callout, user: user, feature_name: described_class::SUGGEST_PIPELINE) - end - - it 'indicates suggest pipeline has been dismissed' do - expect(subject[:is_dismissed_suggest_pipeline]).to be(true) - end - end - - context 'when user is not logged in' do - let(:request) { double('request', current_user: nil, project: project) } - - it 'returns a blank is dismissed value' do - expect(subject[:is_dismissed_suggest_pipeline]).to be_nil - end - end end it 'has human access' do @@ -395,4 +373,46 @@ RSpec.describe MergeRequestWidgetEntity do end end end + + describe 'is_dismissed_suggest_pipeline' do + context 'when user is logged in' do + context 'when the suggest pipeline feature is enabled' do + before do + allow(Gitlab::CurrentSettings).to receive(:suggest_pipeline_enabled?).and_return(true) + end + + it 'is false' do + expect(subject[:is_dismissed_suggest_pipeline]).to be(false) + end + + context 'when suggest pipeline has been dismissed' do + before do + create(:user_callout, user: user, feature_name: described_class::SUGGEST_PIPELINE) + end + + it 'is true' do + expect(subject[:is_dismissed_suggest_pipeline]).to be(true) + end + end + end + + context 'when the suggest pipeline feature is disabled' do + before do + allow(Gitlab::CurrentSettings).to receive(:suggest_pipeline_enabled?).and_return(false) + end + + it 'is true' do + expect(subject[:is_dismissed_suggest_pipeline]).to be(true) + end + end + end + + context 'when user is not logged in' do + let(:request) { double('request', current_user: nil, project: project) } + + it 'is true' do + expect(subject[:is_dismissed_suggest_pipeline]).to be(true) + end + end + end end diff --git a/spec/services/application_settings/update_service_spec.rb b/spec/services/application_settings/update_service_spec.rb index a1fd89bcad7..5c9d2c5e680 100644 --- a/spec/services/application_settings/update_service_spec.rb +++ b/spec/services/application_settings/update_service_spec.rb @@ -413,6 +413,32 @@ RSpec.describe ApplicationSettings::UpdateService do end end + context 'when deprecated API rate limits are passed' do + let(:params) do + { + throttle_unauthenticated_deprecated_api_enabled: 1, + throttle_unauthenticated_deprecated_api_period_in_seconds: 500, + throttle_unauthenticated_deprecated_api_requests_per_period: 20, + throttle_authenticated_deprecated_api_enabled: 1, + throttle_authenticated_deprecated_api_period_in_seconds: 600, + throttle_authenticated_deprecated_api_requests_per_period: 10 + } + end + + it 'updates deprecated API throttle settings' do + subject.execute + + application_settings.reload + + expect(application_settings.throttle_unauthenticated_deprecated_api_enabled).to be_truthy + expect(application_settings.throttle_unauthenticated_deprecated_api_period_in_seconds).to eq(500) + expect(application_settings.throttle_unauthenticated_deprecated_api_requests_per_period).to eq(20) + expect(application_settings.throttle_authenticated_deprecated_api_enabled).to be_truthy + expect(application_settings.throttle_authenticated_deprecated_api_period_in_seconds).to eq(600) + expect(application_settings.throttle_authenticated_deprecated_api_requests_per_period).to eq(10) + end + end + context 'when git lfs rate limits are passed' do let(:params) do { diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb index d1f854f72bc..72027911e51 100644 --- a/spec/services/boards/issues/list_service_spec.rb +++ b/spec/services/boards/issues/list_service_spec.rb @@ -56,12 +56,23 @@ RSpec.describe Boards::Issues::ListService do it_behaves_like 'issues list service' end - context 'when filtering by type' do - it 'only returns the specified type' do - issue = create(:labeled_issue, project: project, milestone: m1, labels: [development, p1], issue_type: 'incident') - params = { board_id: board.id, id: list1.id, issue_types: 'incident' } + context 'when filtering' do + let_it_be(:incident) { create(:labeled_issue, project: project, milestone: m1, labels: [development, p1], issue_type: 'incident') } - expect(described_class.new(parent, user, params).execute).to eq [issue] + context 'when filtering by type' do + it 'only returns the specified type' do + params = { board_id: board.id, id: list1.id, issue_types: 'incident' } + + expect(described_class.new(parent, user, params).execute).to eq [incident] + end + end + + context 'when filtering by negated type' do + it 'only returns the specified type' do + params = { board_id: board.id, id: list1.id, not: { issue_types: ['issue'] } } + + expect(described_class.new(parent, user, params).execute).to contain_exactly(incident) + end end end end diff --git a/spec/services/bulk_import_service_spec.rb b/spec/services/bulk_imports/create_service_spec.rb index 1b60a5cb0f8..67ec6fee1ae 100644 --- a/spec/services/bulk_import_service_spec.rb +++ b/spec/services/bulk_imports/create_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe BulkImportService do +RSpec.describe BulkImports::CreateService do let(:user) { create(:user) } let(:credentials) { { url: 'http://gitlab.example', access_token: 'token' } } let(:params) do @@ -31,8 +31,25 @@ RSpec.describe BulkImportService do subject { described_class.new(user, params, credentials) } describe '#execute' do + let_it_be(:source_version) do + Gitlab::VersionInfo.new(::BulkImport::MIN_MAJOR_VERSION, + ::BulkImport::MIN_MINOR_VERSION_FOR_PROJECT) + end + + before do + allow_next_instance_of(BulkImports::Clients::HTTP) do |instance| + allow(instance).to receive(:instance_version).and_return(source_version) + end + end + it 'creates bulk import' do expect { subject.execute }.to change { BulkImport.count }.by(1) + + last_bulk_import = BulkImport.last + + expect(last_bulk_import.user).to eq(user) + expect(last_bulk_import.source_version).to eq(source_version.to_s) + expect(last_bulk_import.user).to eq(user) end it 'creates bulk import entities' do diff --git a/spec/services/bulk_imports/file_export_service_spec.rb b/spec/services/bulk_imports/file_export_service_spec.rb new file mode 100644 index 00000000000..0d129c75384 --- /dev/null +++ b/spec/services/bulk_imports/file_export_service_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::FileExportService do + let_it_be(:project) { create(:project) } + let_it_be(:export_path) { Dir.mktmpdir } + let_it_be(:relation) { 'uploads' } + + subject(:service) { described_class.new(project, export_path, relation) } + + describe '#execute' do + it 'executes export service and archives exported data' do + expect_next_instance_of(BulkImports::UploadsExportService) do |service| + expect(service).to receive(:execute) + end + + expect(subject).to receive(:tar_cf).with(archive: File.join(export_path, 'uploads.tar'), dir: export_path) + + subject.execute + end + + context 'when unsupported relation is passed' do + it 'raises an error' do + service = described_class.new(project, export_path, 'unsupported') + + expect { service.execute }.to raise_error(BulkImports::Error, 'Unsupported relation export type') + end + end + end + + describe '#exported_filename' do + it 'returns filename of the exported file' do + expect(subject.exported_filename).to eq('uploads.tar') + end + end +end diff --git a/spec/services/bulk_imports/get_importable_data_service_spec.rb b/spec/services/bulk_imports/get_importable_data_service_spec.rb new file mode 100644 index 00000000000..eccd3e5f49d --- /dev/null +++ b/spec/services/bulk_imports/get_importable_data_service_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::GetImportableDataService do + describe '#execute' do + include_context 'bulk imports requests context', 'https://gitlab.example.com' + + let_it_be(:params) { { per_page: 20, page: 1 } } + let_it_be(:query_params) { { top_level_only: true, min_access_level: 50, search: '' } } + let_it_be(:credentials) { { url: 'https://gitlab.example.com', access_token: 'demo-pat' } } + let_it_be(:expected_version_validation) do + { + features: { + project_migration: { + available: true, + min_version: BulkImport.min_gl_version_for_project_migration.to_s + }, + 'source_instance_version': BulkImport.min_gl_version_for_project_migration.to_s + } + } + end + + let_it_be(:expected_parsed_response) do + [ + { + 'id' => 2595438, + 'web_url' => 'https://gitlab.com/groups/auto-breakfast', + 'name' => 'Stub', + 'path' => 'stub-group', + 'full_name' => 'Stub', + 'full_path' => 'stub-group' + } + ] + end + + subject do + described_class.new(params, query_params, credentials).execute + end + + it 'returns version_validation and a response' do + expect(subject[:version_validation]).to eq(expected_version_validation) + expect(subject[:response].parsed_response).to eq(expected_parsed_response) + end + end +end diff --git a/spec/services/bulk_imports/relation_export_service_spec.rb b/spec/services/bulk_imports/relation_export_service_spec.rb index 333cd9201d8..27a6ca60515 100644 --- a/spec/services/bulk_imports/relation_export_service_spec.rb +++ b/spec/services/bulk_imports/relation_export_service_spec.rb @@ -7,12 +7,14 @@ RSpec.describe BulkImports::RelationExportService do let_it_be(:relation) { 'labels' } let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project) } let_it_be(:label) { create(:group_label, group: group) } let_it_be(:export_path) { "#{Dir.tmpdir}/relation_export_service_spec/tree" } let_it_be_with_reload(:export) { create(:bulk_import_export, group: group, relation: relation) } before do group.add_owner(user) + project.add_maintainer(user) allow(export).to receive(:export_path).and_return(export_path) end @@ -25,6 +27,10 @@ RSpec.describe BulkImports::RelationExportService do describe '#execute' do it 'exports specified relation and marks export as finished' do + expect_next_instance_of(BulkImports::TreeExportService) do |service| + expect(service).to receive(:execute).and_call_original + end + subject.execute expect(export.reload.upload.export_file).to be_present @@ -43,6 +49,18 @@ RSpec.describe BulkImports::RelationExportService do expect(export.upload.export_file).to be_present end + context 'when exporting a file relation' do + it 'uses file export service' do + service = described_class.new(user, project, 'uploads', jid) + + expect_next_instance_of(BulkImports::FileExportService) do |service| + expect(service).to receive(:execute) + end + + service.execute + end + end + context 'when export record does not exist' do let(:another_group) { create(:group) } diff --git a/spec/services/bulk_imports/tree_export_service_spec.rb b/spec/services/bulk_imports/tree_export_service_spec.rb new file mode 100644 index 00000000000..f2ed747b64e --- /dev/null +++ b/spec/services/bulk_imports/tree_export_service_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::TreeExportService do + let_it_be(:project) { create(:project) } + let_it_be(:export_path) { Dir.mktmpdir } + let_it_be(:relation) { 'issues' } + + subject(:service) { described_class.new(project, export_path, relation) } + + describe '#execute' do + it 'executes export service and archives exported data' do + expect_next_instance_of(Gitlab::ImportExport::Json::StreamingSerializer) do |serializer| + expect(serializer).to receive(:serialize_relation) + end + + subject.execute + end + + context 'when unsupported relation is passed' do + it 'raises an error' do + service = described_class.new(project, export_path, 'unsupported') + + expect { service.execute }.to raise_error(BulkImports::Error, 'Unsupported relation export type') + end + end + end + + describe '#exported_filename' do + it 'returns filename of the exported file' do + expect(subject.exported_filename).to eq('issues.ndjson') + end + end +end diff --git a/spec/services/ci/archive_trace_service_spec.rb b/spec/services/ci/archive_trace_service_spec.rb index 071b5c3b2f9..b08ba6fd5e5 100644 --- a/spec/services/ci/archive_trace_service_spec.rb +++ b/spec/services/ci/archive_trace_service_spec.rb @@ -88,6 +88,32 @@ RSpec.describe Ci::ArchiveTraceService, '#execute' do subject end + + context 'job has archive and chunks' do + let(:job) { create(:ci_build, :success, :trace_artifact) } + + before do + create(:ci_build_trace_chunk, build: job, chunk_index: 0) + end + + context 'archive is not completed' do + before do + job.job_artifacts_trace.file.remove! + end + + it 'cleanups any stale archive data' do + expect(job.job_artifacts_trace).to be_present + + subject + + expect(job.reload.job_artifacts_trace).to be_nil + end + end + + it 'removes trace chunks' do + expect { subject }.to change { job.trace_chunks.count }.to(0) + end + end end context 'when the archival process is backed off' do diff --git a/spec/services/ci/create_pipeline_service/include_spec.rb b/spec/services/ci/create_pipeline_service/include_spec.rb index 46271ee36c0..5e7dace8e15 100644 --- a/spec/services/ci/create_pipeline_service/include_spec.rb +++ b/spec/services/ci/create_pipeline_service/include_spec.rb @@ -58,17 +58,6 @@ RSpec.describe Ci::CreatePipelineService do expect(pipeline).to be_created_successfully expect(pipeline.processables.pluck(:name)).to contain_exactly('job', 'rspec') end - - context 'when the FF ci_include_rules is disabled' do - before do - stub_feature_flags(ci_include_rules: false) - end - - it 'includes the job in the file' do - expect(pipeline).to be_created_successfully - expect(pipeline.processables.pluck(:name)).to contain_exactly('job', 'rspec') - end - end end context 'when the rules does not match' do @@ -78,17 +67,6 @@ RSpec.describe Ci::CreatePipelineService do expect(pipeline).to be_created_successfully expect(pipeline.processables.pluck(:name)).to contain_exactly('job') end - - context 'when the FF ci_include_rules is disabled' do - before do - stub_feature_flags(ci_include_rules: false) - end - - it 'includes the job in the file' do - expect(pipeline).to be_created_successfully - expect(pipeline.processables.pluck(:name)).to contain_exactly('job', 'rspec') - end - end end end end diff --git a/spec/services/ci/drop_pipeline_service_spec.rb b/spec/services/ci/drop_pipeline_service_spec.rb index c6a118c6083..ddb53712d9c 100644 --- a/spec/services/ci/drop_pipeline_service_spec.rb +++ b/spec/services/ci/drop_pipeline_service_spec.rb @@ -50,13 +50,14 @@ RSpec.describe Ci::DropPipelineService do end.count writes_per_build = 2 + load_balancer_queries = 3 expected_reads_count = control_count - writes_per_build create_list(:ci_build, 5, :running, pipeline: cancelable_pipeline) expect do drop_pipeline!(cancelable_pipeline) - end.not_to exceed_query_limit(expected_reads_count + (5 * writes_per_build)) + end.not_to exceed_query_limit(expected_reads_count + (5 * writes_per_build) + load_balancer_queries) end end end diff --git a/spec/services/ci/pipelines/add_job_service_spec.rb b/spec/services/ci/pipelines/add_job_service_spec.rb index 3a77d26dd9e..709a840c644 100644 --- a/spec/services/ci/pipelines/add_job_service_spec.rb +++ b/spec/services/ci/pipelines/add_job_service_spec.rb @@ -77,19 +77,6 @@ RSpec.describe Ci::Pipelines::AddJobService do expect(execute).to be_success expect(execute.payload[:job]).to eq(job) end - - context 'when the FF ci_pipeline_add_job_with_lock is disabled' do - before do - stub_feature_flags(ci_pipeline_add_job_with_lock: false) - end - - it 'does not use exclusive lock' do - expect(Gitlab::ExclusiveLease).not_to receive(:new).with(lock_key, timeout: lock_timeout) - - expect(execute).to be_success - expect(execute.payload[:job]).to eq(job) - end - end end end end diff --git a/spec/services/ci/pipelines/hook_service_spec.rb b/spec/services/ci/pipelines/hook_service_spec.rb new file mode 100644 index 00000000000..0e1ef6afd0d --- /dev/null +++ b/spec/services/ci/pipelines/hook_service_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::Pipelines::HookService do + describe '#execute_hooks' do + let_it_be(:namespace) { create(:namespace) } + let_it_be(:project) { create(:project, :repository, namespace: namespace) } + let_it_be(:pipeline) { create(:ci_empty_pipeline, :created, project: project) } + + let(:hook_enabled) { true } + let!(:hook) { create(:project_hook, project: project, pipeline_events: hook_enabled) } + let(:hook_data) { double } + + subject(:service) { described_class.new(pipeline) } + + describe 'HOOK_NAME' do + specify { expect(described_class::HOOK_NAME).to eq(:pipeline_hooks) } + end + + context 'with pipeline hooks enabled' do + before do + allow(Gitlab::DataBuilder::Pipeline).to receive(:build).with(pipeline).once.and_return(hook_data) + end + + it 'calls pipeline.project.execute_hooks and pipeline.project.execute_integrations' do + create(:pipelines_email_integration, project: project) + + expect(pipeline.project).to receive(:execute_hooks).with(hook_data, described_class::HOOK_NAME) + expect(pipeline.project).to receive(:execute_integrations).with(hook_data, described_class::HOOK_NAME) + + service.execute + end + end + + context 'with pipeline hooks and integrations disabled' do + let(:hook_enabled) { false } + + it 'does not call pipeline.project.execute_hooks and pipeline.project.execute_integrations' do + expect(pipeline.project).not_to receive(:execute_hooks) + expect(pipeline.project).not_to receive(:execute_integrations) + + service.execute + end + end + end +end diff --git a/spec/services/ci/play_bridge_service_spec.rb b/spec/services/ci/play_bridge_service_spec.rb index 3f97bfdf5ae..56b1615a56d 100644 --- a/spec/services/ci/play_bridge_service_spec.rb +++ b/spec/services/ci/play_bridge_service_spec.rb @@ -23,18 +23,18 @@ RSpec.describe Ci::PlayBridgeService, '#execute' do expect(bridge.reload).to be_pending end - it 'enqueues Ci::CreateCrossProjectPipelineWorker' do - expect(::Ci::CreateCrossProjectPipelineWorker).to receive(:perform_async).with(bridge.id) - - execute_service - end - it "updates bridge's user" do execute_service expect(bridge.reload.user).to eq(user) end + it 'enqueues Ci::CreateDownstreamPipelineWorker' do + expect(::Ci::CreateDownstreamPipelineWorker).to receive(:perform_async).with(bridge.id) + + execute_service + end + context 'when a subsequent job is skipped' do let!(:job) { create(:ci_build, :skipped, pipeline: pipeline, stage_idx: bridge.stage_idx + 1) } diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index b5bf0adadaf..404e1bf7c87 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -10,11 +10,9 @@ RSpec.describe Ci::ProcessPipelineService do end let(:pipeline_processing_events_counter) { double(increment: true) } - let(:legacy_update_jobs_counter) { double(increment: true) } let(:metrics) do - double(pipeline_processing_events_counter: pipeline_processing_events_counter, - legacy_update_jobs_counter: legacy_update_jobs_counter) + double(pipeline_processing_events_counter: pipeline_processing_events_counter) end subject { described_class.new(pipeline) } @@ -33,68 +31,4 @@ RSpec.describe Ci::ProcessPipelineService do subject.execute end end - - describe 'updating a list of retried builds' do - let!(:build_retried) { create_build('build') } - let!(:build) { create_build('build') } - let!(:test) { create_build('test') } - - context 'when FF ci_remove_update_retried_from_process_pipeline is enabled' do - it 'does not update older builds as retried' do - subject.execute - - expect(all_builds.latest).to contain_exactly(build, build_retried, test) - expect(all_builds.retried).to be_empty - end - end - - context 'when FF ci_remove_update_retried_from_process_pipeline is disabled' do - before do - stub_feature_flags(ci_remove_update_retried_from_process_pipeline: false) - end - - it 'returns unique statuses' do - subject.execute - - expect(all_builds.latest).to contain_exactly(build, test) - expect(all_builds.retried).to contain_exactly(build_retried) - end - - it 'increments the counter' do - expect(legacy_update_jobs_counter).to receive(:increment) - - subject.execute - end - - it 'logs the project and pipeline id' do - expect(Gitlab::AppJsonLogger).to receive(:info).with(event: 'update_retried_is_used', - project_id: project.id, - pipeline_id: pipeline.id) - - subject.execute - end - - context 'when the previous build has already retried column true' do - before do - build_retried.update_columns(retried: true) - end - - it 'does not increment the counter' do - expect(legacy_update_jobs_counter).not_to receive(:increment) - - subject.execute - end - end - end - - private - - def create_build(name, **opts) - create(:ci_build, :created, pipeline: pipeline, name: name, **opts) - end - - def all_builds - pipeline.builds.order(:stage_idx, :id) - end - end end diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index 73ff15ec393..650353eb751 100644 --- a/spec/services/ci/register_job_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -14,7 +14,7 @@ module Ci let!(:pending_job) { create(:ci_build, :pending, :queued, pipeline: pipeline) } describe '#execute' do - context 'checks database loadbalancing stickiness', :db_load_balancing do + context 'checks database loadbalancing stickiness' do subject { described_class.new(shared_runner).execute } before do @@ -22,14 +22,14 @@ module Ci end it 'result is valid if replica did caught-up' do - expect(Gitlab::Database::LoadBalancing::Sticking).to receive(:all_caught_up?) + expect(ApplicationRecord.sticking).to receive(:all_caught_up?) .with(:runner, shared_runner.id) { true } expect(subject).to be_valid end it 'result is invalid if replica did not caught-up' do - expect(Gitlab::Database::LoadBalancing::Sticking).to receive(:all_caught_up?) + expect(ApplicationRecord.sticking).to receive(:all_caught_up?) .with(:runner, shared_runner.id) { false } expect(subject).not_to be_valid @@ -87,19 +87,25 @@ module Ci end context 'for specific runner' do - context 'with FF disabled' do + context 'with tables decoupling disabled' do before do stub_feature_flags( ci_pending_builds_project_runners_decoupling: false, ci_queueing_builds_enabled_checks: false) end + around do |example| + allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/332952') do + example.run + end + end + it 'does not pick a build' do expect(execute(specific_runner)).to be_nil end end - context 'with FF enabled' do + context 'with tables decoupling enabled' do before do stub_feature_flags( ci_pending_builds_project_runners_decoupling: true, @@ -266,17 +272,23 @@ module Ci context 'and uses project runner' do let(:build) { execute(specific_runner) } - context 'with FF disabled' do + context 'with tables decoupling disabled' do before do stub_feature_flags( ci_pending_builds_project_runners_decoupling: false, ci_queueing_builds_enabled_checks: false) end + around do |example| + allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/332952') do + example.run + end + end + it { expect(build).to be_nil } end - context 'with FF enabled' do + context 'with tables decoupling enabled' do before do stub_feature_flags( ci_pending_builds_project_runners_decoupling: true, @@ -791,6 +803,12 @@ module Ci stub_feature_flags(ci_queueing_denormalize_shared_runners_information: false) end + around do |example| + allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/332952') do + example.run + end + end + include_examples 'handles runner assignment' end @@ -807,6 +825,12 @@ module Ci stub_feature_flags(ci_queueing_denormalize_tags_information: false) end + around do |example| + allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/332952') do + example.run + end + end + include_examples 'handles runner assignment' end @@ -815,6 +839,12 @@ module Ci stub_feature_flags(ci_queueing_denormalize_namespace_traversal_ids: false) end + around do |example| + allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/332952') do + example.run + end + end + include_examples 'handles runner assignment' end end @@ -824,6 +854,12 @@ module Ci stub_feature_flags(ci_pending_builds_queue_source: false) end + around do |example| + allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/332952') do + example.run + end + end + include_examples 'handles runner assignment' end end diff --git a/spec/services/ci/resource_groups/assign_resource_from_resource_group_service_spec.rb b/spec/services/ci/resource_groups/assign_resource_from_resource_group_service_spec.rb index 53aa842bc28..194203a422c 100644 --- a/spec/services/ci/resource_groups/assign_resource_from_resource_group_service_spec.rb +++ b/spec/services/ci/resource_groups/assign_resource_from_resource_group_service_spec.rb @@ -48,6 +48,92 @@ RSpec.describe Ci::ResourceGroups::AssignResourceFromResourceGroupService do expect(build).to be_pending end end + + context 'when process mode is oldest_first' do + let(:resource_group) { create(:ci_resource_group, process_mode: :oldest_first, project: project) } + + it 'requests resource' do + subject + + expect(build.reload).to be_pending + expect(build.resource).to be_present + end + + context 'when the other job exists in the newer pipeline' do + let!(:build_2) { create(:ci_build, :waiting_for_resource, project: project, user: user, resource_group: resource_group) } + + it 'requests resource for the job in the oldest pipeline' do + subject + + expect(build.reload).to be_pending + expect(build.resource).to be_present + expect(build_2.reload).to be_waiting_for_resource + expect(build_2.resource).to be_nil + end + end + + context 'when build is not `waiting_for_resource` state' do + let!(:build) { create(:ci_build, :created, project: project, user: user, resource_group: resource_group) } + + it 'attempts to request a resource' do + expect_next_found_instance_of(Ci::Build) do |job| + expect(job).to receive(:enqueue_waiting_for_resource).and_call_original + end + + subject + end + + it 'does not change the job status' do + subject + + expect(build.reload).to be_created + expect(build.resource).to be_nil + end + end + end + + context 'when process mode is newest_first' do + let(:resource_group) { create(:ci_resource_group, process_mode: :newest_first, project: project) } + + it 'requests resource' do + subject + + expect(build.reload).to be_pending + expect(build.resource).to be_present + end + + context 'when the other job exists in the newer pipeline' do + let!(:build_2) { create(:ci_build, :waiting_for_resource, project: project, user: user, resource_group: resource_group) } + + it 'requests resource for the job in the newest pipeline' do + subject + + expect(build.reload).to be_waiting_for_resource + expect(build.resource).to be_nil + expect(build_2.reload).to be_pending + expect(build_2.resource).to be_present + end + end + + context 'when build is not `waiting_for_resource` state' do + let!(:build) { create(:ci_build, :created, project: project, user: user, resource_group: resource_group) } + + it 'attempts to request a resource' do + expect_next_found_instance_of(Ci::Build) do |job| + expect(job).to receive(:enqueue_waiting_for_resource).and_call_original + end + + subject + end + + it 'does not change the job status' do + subject + + expect(build.reload).to be_created + expect(build.resource).to be_nil + end + end + end end context 'when there are no available resources' do diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index ce2e6ba5e15..15c88c9f657 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -48,7 +48,7 @@ RSpec.describe Ci::RetryBuildService do job_artifacts_network_referee job_artifacts_dotenv job_artifacts_cobertura needs job_artifacts_accessibility job_artifacts_requirements job_artifacts_coverage_fuzzing - job_artifacts_api_fuzzing].freeze + job_artifacts_api_fuzzing terraform_state_versions].freeze ignore_accessors = %i[type lock_version target_url base_tags trace_sections @@ -88,6 +88,7 @@ RSpec.describe Ci::RetryBuildService do create(:ci_job_variable, job: build) create(:ci_build_need, build: build) + create(:terraform_state_version, build: build) end describe 'clone accessors' do @@ -276,13 +277,17 @@ RSpec.describe Ci::RetryBuildService do end end - describe '#reprocess' do + describe '#clone!' do let(:new_build) do travel_to(1.second.from_now) do - service.reprocess!(build) + service.clone!(build) end end + it 'raises an error when an unexpected class is passed' do + expect { service.clone!(create(:ci_build).present) }.to raise_error(TypeError) + end + context 'when user has ability to execute build' do before do stub_not_protect_default_branch @@ -338,7 +343,7 @@ RSpec.describe Ci::RetryBuildService do let(:user) { reporter } it 'raises an error' do - expect { service.reprocess!(build) } + expect { service.clone!(build) } .to raise_error Gitlab::Access::AccessDeniedError end end diff --git a/spec/services/ci/retry_pipeline_service_spec.rb b/spec/services/ci/retry_pipeline_service_spec.rb index 3e2e9f07723..12106b70969 100644 --- a/spec/services/ci/retry_pipeline_service_spec.rb +++ b/spec/services/ci/retry_pipeline_service_spec.rb @@ -316,6 +316,26 @@ RSpec.describe Ci::RetryPipelineService, '#execute' do expect(bridge.reload).to be_pending end end + + context 'when there are skipped jobs in later stages' do + before do + create_build('build 1', :success, 0) + create_build('test 2', :failed, 1) + create_build('report 3', :skipped, 2) + create_bridge('deploy 4', :skipped, 2) + end + + it 'retries failed jobs and processes skipped jobs' do + service.execute(pipeline) + + expect(build('build 1')).to be_success + expect(build('test 2')).to be_pending + expect(build('report 3')).to be_created + expect(build('deploy 4')).to be_created + + expect(pipeline.reload).to be_running + end + end end context 'when user is not allowed to retry pipeline' do @@ -390,16 +410,25 @@ RSpec.describe Ci::RetryPipelineService, '#execute' do pipeline.reload.statuses end + # The method name can be confusing because this can actually return both Ci::Build and Ci::Bridge def build(name) statuses.latest.find_by(name: name) end def create_build(name, status, stage_num, **opts) - create(:ci_build, name: name, - status: status, - stage: "stage_#{stage_num}", - stage_idx: stage_num, - pipeline: pipeline, **opts) do |build| + create_processable(:ci_build, name, status, stage_num, **opts) + end + + def create_bridge(name, status, stage_num, **opts) + create_processable(:ci_bridge, name, status, stage_num, **opts) + end + + def create_processable(type, name, status, stage_num, **opts) + create(type, name: name, + status: status, + stage: "stage_#{stage_num}", + stage_idx: stage_num, + pipeline: pipeline, **opts) do |_job| ::Ci::ProcessPipelineService.new(pipeline).execute end end diff --git a/spec/services/ci/stuck_builds/drop_service_spec.rb b/spec/services/ci/stuck_builds/drop_pending_service_spec.rb index 8dfd1bc1b3d..aa0526edf57 100644 --- a/spec/services/ci/stuck_builds/drop_service_spec.rb +++ b/spec/services/ci/stuck_builds/drop_pending_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Ci::StuckBuilds::DropService do +RSpec.describe Ci::StuckBuilds::DropPendingService do let!(:runner) { create :ci_runner } let!(:job) { create :ci_build, runner: runner } let(:created_at) { } @@ -17,48 +17,6 @@ RSpec.describe Ci::StuckBuilds::DropService do job.update!(job_attributes) end - shared_examples 'job is dropped' do - it 'changes status' do - expect(service).to receive(:drop).exactly(3).times.and_call_original - expect(service).to receive(:drop_stuck).exactly(:once).and_call_original - - service.execute - job.reload - - expect(job).to be_failed - expect(job).to be_stuck_or_timeout_failure - end - - context 'when job have data integrity problem' do - it "does drop the job and logs the reason" do - job.update_columns(yaml_variables: '[{"key" => "value"}]') - - expect(Gitlab::ErrorTracking).to receive(:track_exception) - .with(anything, a_hash_including(build_id: job.id)) - .once - .and_call_original - - service.execute - job.reload - - expect(job).to be_failed - expect(job).to be_data_integrity_failure - end - end - end - - shared_examples 'job is unchanged' do - it 'does not change status' do - expect(service).to receive(:drop).exactly(3).times.and_call_original - expect(service).to receive(:drop_stuck).exactly(:once).and_call_original - - service.execute - job.reload - - expect(job.status).to eq(status) - end - end - context 'when job is pending' do let(:status) { 'pending' } @@ -75,13 +33,13 @@ RSpec.describe Ci::StuckBuilds::DropService do context 'when created_at is the same as updated_at' do let(:created_at) { 1.5.days.ago } - it_behaves_like 'job is dropped' + it_behaves_like 'job is dropped with failure reason', 'stuck_or_timeout_failure' end context 'when created_at is before updated_at' do let(:created_at) { 3.days.ago } - it_behaves_like 'job is dropped' + it_behaves_like 'job is dropped with failure reason', 'stuck_or_timeout_failure' end context 'when created_at is outside lookback window' do @@ -149,13 +107,13 @@ RSpec.describe Ci::StuckBuilds::DropService do context 'when created_at is the same as updated_at' do let(:created_at) { 1.5.hours.ago } - it_behaves_like 'job is dropped' + it_behaves_like 'job is dropped with failure reason', 'stuck_or_timeout_failure' end context 'when created_at is before updated_at' do let(:created_at) { 3.days.ago } - it_behaves_like 'job is dropped' + it_behaves_like 'job is dropped with failure reason', 'stuck_or_timeout_failure' end context 'when created_at is outside lookback window' do @@ -195,7 +153,7 @@ RSpec.describe Ci::StuckBuilds::DropService do context 'when job was updated_at more than an hour ago' do let(:updated_at) { 2.hours.ago } - it_behaves_like 'job is dropped' + it_behaves_like 'job is unchanged' end context 'when job was updated in less than 1 hour ago' do @@ -238,47 +196,6 @@ RSpec.describe Ci::StuckBuilds::DropService do job.project.update!(pending_delete: true) end - it_behaves_like 'job is dropped' - end - - describe 'drop stale scheduled builds' do - let(:status) { 'scheduled' } - let(:updated_at) { } - - context 'when scheduled at 2 hours ago but it is not executed yet' do - let!(:job) { create(:ci_build, :scheduled, scheduled_at: 2.hours.ago) } - - it 'drops the stale scheduled build' do - expect(Ci::Build.scheduled.count).to eq(1) - expect(job).to be_scheduled - - service.execute - job.reload - - expect(Ci::Build.scheduled.count).to eq(0) - expect(job).to be_failed - expect(job).to be_stale_schedule - end - end - - context 'when scheduled at 30 minutes ago but it is not executed yet' do - let!(:job) { create(:ci_build, :scheduled, scheduled_at: 30.minutes.ago) } - - it 'does not drop the stale scheduled build yet' do - expect(Ci::Build.scheduled.count).to eq(1) - expect(job).to be_scheduled - - service.execute - - expect(Ci::Build.scheduled.count).to eq(1) - expect(job).to be_scheduled - end - end - - context 'when there are no stale scheduled builds' do - it 'does not drop the stale scheduled build yet' do - expect { service.execute }.not_to raise_error - end - end + it_behaves_like 'job is unchanged' end end diff --git a/spec/services/ci/stuck_builds/drop_running_service_spec.rb b/spec/services/ci/stuck_builds/drop_running_service_spec.rb new file mode 100644 index 00000000000..c1c92c2b8e2 --- /dev/null +++ b/spec/services/ci/stuck_builds/drop_running_service_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::StuckBuilds::DropRunningService do + let!(:runner) { create :ci_runner } + let!(:job) { create(:ci_build, runner: runner, created_at: created_at, updated_at: updated_at, status: status) } + + subject(:service) { described_class.new } + + around do |example| + freeze_time { example.run } + end + + shared_examples 'running builds' do + context 'when job is running' do + let(:status) { 'running' } + let(:outdated_time) { described_class::BUILD_RUNNING_OUTDATED_TIMEOUT.ago - 30.minutes } + let(:fresh_time) { described_class::BUILD_RUNNING_OUTDATED_TIMEOUT.ago + 30.minutes } + + context 'when job is outdated' do + let(:created_at) { outdated_time } + let(:updated_at) { outdated_time } + + it_behaves_like 'job is dropped with failure reason', 'stuck_or_timeout_failure' + end + + context 'when job is fresh' do + let(:created_at) { fresh_time } + let(:updated_at) { fresh_time } + + it_behaves_like 'job is unchanged' + end + + context 'when job freshly updated' do + let(:created_at) { outdated_time } + let(:updated_at) { fresh_time } + + it_behaves_like 'job is unchanged' + end + end + end + + include_examples 'running builds' + + context 'when new query flag is disabled' do + before do + stub_feature_flags(ci_new_query_for_running_stuck_jobs: false) + end + + include_examples 'running builds' + end + + %w(success skipped failed canceled scheduled pending).each do |status| + context "when job is #{status}" do + let(:status) { status } + let(:updated_at) { 2.days.ago } + + context 'when created_at is the same as updated_at' do + let(:created_at) { 2.days.ago } + + it_behaves_like 'job is unchanged' + end + + context 'when created_at is before updated_at' do + let(:created_at) { 3.days.ago } + + it_behaves_like 'job is unchanged' + end + end + end +end diff --git a/spec/services/ci/stuck_builds/drop_scheduled_service_spec.rb b/spec/services/ci/stuck_builds/drop_scheduled_service_spec.rb new file mode 100644 index 00000000000..1416fab3d25 --- /dev/null +++ b/spec/services/ci/stuck_builds/drop_scheduled_service_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::StuckBuilds::DropScheduledService do + let_it_be(:runner) { create :ci_runner } + + let!(:job) { create :ci_build, :scheduled, scheduled_at: scheduled_at, runner: runner } + + subject(:service) { described_class.new } + + context 'when job is scheduled' do + context 'for more than an hour ago' do + let(:scheduled_at) { 2.hours.ago } + + it_behaves_like 'job is dropped with failure reason', 'stale_schedule' + end + + context 'for less than 1 hour ago' do + let(:scheduled_at) { 30.minutes.ago } + + it_behaves_like 'job is unchanged' + end + end + + %w(success skipped failed canceled running pending).each do |status| + context "when job is #{status}" do + before do + job.update!(status: status) + end + + context 'and scheduled for more than an hour ago' do + let(:scheduled_at) { 2.hours.ago } + + it_behaves_like 'job is unchanged' + end + + context 'and scheduled for less than 1 hour ago' do + let(:scheduled_at) { 30.minutes.ago } + + it_behaves_like 'job is unchanged' + end + end + end + + context 'when there are no stale scheduled builds' do + let(:job) { } + + it 'does not drop the stale scheduled build yet' do + expect { service.execute }.not_to raise_error + end + end +end diff --git a/spec/services/ci/update_build_state_service_spec.rb b/spec/services/ci/update_build_state_service_spec.rb index 5bb3843da8f..e4dd3d0500f 100644 --- a/spec/services/ci/update_build_state_service_spec.rb +++ b/spec/services/ci/update_build_state_service_spec.rb @@ -112,6 +112,14 @@ RSpec.describe Ci::UpdateBuildStateService do .not_to have_received(:increment_trace_operation) .with(operation: :invalid) end + + it 'does not increment chunks_invalid_checksum trace metric' do + execute_with_stubbed_metrics! + + expect(metrics) + .not_to have_received(:increment_error_counter) + .with(type: :chunks_invalid_checksum) + end end context 'when build trace has been migrated' do @@ -174,6 +182,14 @@ RSpec.describe Ci::UpdateBuildStateService do .to have_received(:increment_trace_operation) .with(operation: :invalid) end + + it 'increments chunks_invalid_checksum trace metric' do + execute_with_stubbed_metrics! + + expect(metrics) + .to have_received(:increment_error_counter) + .with(type: :chunks_invalid_checksum) + end end context 'when trace checksum is valid' do @@ -191,6 +207,14 @@ RSpec.describe Ci::UpdateBuildStateService do expect(metrics) .not_to have_received(:increment_trace_operation) .with(operation: :corrupted) + + expect(metrics) + .not_to have_received(:increment_error_counter) + .with(type: :chunks_invalid_checksum) + + expect(metrics) + .not_to have_received(:increment_error_counter) + .with(type: :chunks_invalid_size) end context 'when using deprecated parameters' do @@ -208,6 +232,14 @@ RSpec.describe Ci::UpdateBuildStateService do expect(metrics) .not_to have_received(:increment_trace_operation) .with(operation: :corrupted) + + expect(metrics) + .not_to have_received(:increment_error_counter) + .with(type: :chunks_invalid_checksum) + + expect(metrics) + .not_to have_received(:increment_error_counter) + .with(type: :chunks_invalid_size) end end end @@ -227,6 +259,14 @@ RSpec.describe Ci::UpdateBuildStateService do expect(metrics) .to have_received(:increment_trace_operation) .with(operation: :corrupted) + + expect(metrics) + .to have_received(:increment_error_counter) + .with(type: :chunks_invalid_checksum) + + expect(metrics) + .to have_received(:increment_error_counter) + .with(type: :chunks_invalid_size) end end @@ -243,8 +283,16 @@ RSpec.describe Ci::UpdateBuildStateService do .with(operation: :invalid) expect(metrics) + .to have_received(:increment_error_counter) + .with(type: :chunks_invalid_checksum) + + expect(metrics) .not_to have_received(:increment_trace_operation) .with(operation: :corrupted) + + expect(metrics) + .not_to have_received(:increment_error_counter) + .with(type: :chunks_invalid_size) end end @@ -325,6 +373,10 @@ RSpec.describe Ci::UpdateBuildStateService do expect(metrics) .not_to have_received(:increment_trace_operation) .with(operation: :invalid) + + expect(metrics) + .not_to have_received(:increment_error_counter) + .with(type: :chunks_invalid_checksum) end context 'when build pending state is outdated' do diff --git a/spec/services/ci/update_pending_build_service_spec.rb b/spec/services/ci/update_pending_build_service_spec.rb index d842042de40..d36564938c8 100644 --- a/spec/services/ci/update_pending_build_service_spec.rb +++ b/spec/services/ci/update_pending_build_service_spec.rb @@ -3,21 +3,23 @@ require 'spec_helper' RSpec.describe Ci::UpdatePendingBuildService do - describe '#execute' do - let_it_be(:group) { create(:group) } - let_it_be(:project) { create(:project, namespace: group) } - let_it_be(:pending_build_1) { create(:ci_pending_build, project: project, instance_runners_enabled: false) } - let_it_be(:pending_build_2) { create(:ci_pending_build, project: project, instance_runners_enabled: true) } - let_it_be(:update_params) { { instance_runners_enabled: true } } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, namespace: group) } + let_it_be_with_reload(:pending_build_1) { create(:ci_pending_build, project: project, instance_runners_enabled: false) } + let_it_be_with_reload(:pending_build_2) { create(:ci_pending_build, project: project, instance_runners_enabled: true) } + let_it_be(:update_params) { { instance_runners_enabled: true } } + + let(:service) { described_class.new(model, update_params) } - subject(:service) { described_class.new(model, update_params).execute } + describe '#execute' do + subject(:update_pending_builds) { service.execute } context 'validations' do context 'when model is invalid' do let(:model) { pending_build_1 } it 'raises an error' do - expect { service }.to raise_error(described_class::InvalidModelError) + expect { update_pending_builds }.to raise_error(described_class::InvalidModelError) end end @@ -26,7 +28,7 @@ RSpec.describe Ci::UpdatePendingBuildService do let(:update_params) { { minutes_exceeded: true } } it 'raises an error' do - expect { service }.to raise_error(described_class::InvalidParamsError) + expect { update_pending_builds }.to raise_error(described_class::InvalidParamsError) end end end @@ -35,10 +37,10 @@ RSpec.describe Ci::UpdatePendingBuildService do let(:model) { group } it 'updates all pending builds', :aggregate_failures do - service + update_pending_builds - expect(pending_build_1.reload.instance_runners_enabled).to be_truthy - expect(pending_build_2.reload.instance_runners_enabled).to be_truthy + expect(pending_build_1.instance_runners_enabled).to be_truthy + expect(pending_build_2.instance_runners_enabled).to be_truthy end context 'when ci_pending_builds_maintain_shared_runners_data is disabled' do @@ -47,10 +49,10 @@ RSpec.describe Ci::UpdatePendingBuildService do end it 'does not update all pending builds', :aggregate_failures do - service + update_pending_builds - expect(pending_build_1.reload.instance_runners_enabled).to be_falsey - expect(pending_build_2.reload.instance_runners_enabled).to be_truthy + expect(pending_build_1.instance_runners_enabled).to be_falsey + expect(pending_build_2.instance_runners_enabled).to be_truthy end end end @@ -59,10 +61,10 @@ RSpec.describe Ci::UpdatePendingBuildService do let(:model) { project } it 'updates all pending builds', :aggregate_failures do - service + update_pending_builds - expect(pending_build_1.reload.instance_runners_enabled).to be_truthy - expect(pending_build_2.reload.instance_runners_enabled).to be_truthy + expect(pending_build_1.instance_runners_enabled).to be_truthy + expect(pending_build_2.instance_runners_enabled).to be_truthy end context 'when ci_pending_builds_maintain_shared_runners_data is disabled' do @@ -71,10 +73,10 @@ RSpec.describe Ci::UpdatePendingBuildService do end it 'does not update all pending builds', :aggregate_failures do - service + update_pending_builds - expect(pending_build_1.reload.instance_runners_enabled).to be_falsey - expect(pending_build_2.reload.instance_runners_enabled).to be_truthy + expect(pending_build_1.instance_runners_enabled).to be_falsey + expect(pending_build_2.instance_runners_enabled).to be_truthy end end end diff --git a/spec/services/clusters/agent_tokens/create_service_spec.rb b/spec/services/clusters/agent_tokens/create_service_spec.rb new file mode 100644 index 00000000000..92629af06c8 --- /dev/null +++ b/spec/services/clusters/agent_tokens/create_service_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::AgentTokens::CreateService do + subject(:service) { described_class.new(container: project, current_user: user, params: params) } + + let_it_be(:user) { create(:user) } + + let(:cluster_agent) { create(:cluster_agent) } + let(:project) { cluster_agent.project } + let(:params) { { agent_id: cluster_agent.id, description: 'token description', name: 'token name' } } + + describe '#execute' do + subject { service.execute } + + it 'does not create a new token due to user permissions' do + expect { subject }.not_to change(::Clusters::AgentToken, :count) + end + + it 'returns permission errors', :aggregate_failures do + expect(subject.status).to eq(:error) + expect(subject.message).to eq('User has insufficient permissions to create a token for this project') + end + + context 'with user permissions' do + before do + project.add_maintainer(user) + end + + it 'creates a new token' do + expect { subject }.to change { ::Clusters::AgentToken.count }.by(1) + end + + it 'returns success status', :aggregate_failures do + expect(subject.status).to eq(:success) + expect(subject.message).to be_nil + end + + it 'returns token information', :aggregate_failures do + token = subject.payload[:token] + + expect(subject.payload[:secret]).not_to be_nil + + expect(token.created_by_user).to eq(user) + expect(token.description).to eq(params[:description]) + expect(token.name).to eq(params[:name]) + end + + context 'when params are invalid' do + let(:params) { { agent_id: 'bad_id' } } + + it 'does not create a new token' do + expect { subject }.not_to change(::Clusters::AgentToken, :count) + end + + it 'returns validation errors', :aggregate_failures do + expect(subject.status).to eq(:error) + expect(subject.message).to eq(["Agent must exist", "Name can't be blank"]) + end + end + end + end +end diff --git a/spec/services/clusters/agents/create_service_spec.rb b/spec/services/clusters/agents/create_service_spec.rb new file mode 100644 index 00000000000..2b3bbcae13c --- /dev/null +++ b/spec/services/clusters/agents/create_service_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::CreateService do + subject(:service) { described_class.new(project, user) } + + let(:project) { create(:project, :public, :repository) } + let(:user) { create(:user) } + + describe '#execute' do + context 'without user permissions' do + it 'returns errors when user does not have permissions' do + expect(service.execute(name: 'missing-permissions')).to eq({ + status: :error, + message: 'You have insufficient permissions to create a cluster agent for this project' + }) + end + end + + context 'with user permissions' do + before do + project.add_maintainer(user) + end + + it 'creates a new clusters_agent' do + expect { service.execute(name: 'with-user') }.to change { ::Clusters::Agent.count }.by(1) + end + + it 'returns success status', :aggregate_failures do + result = service.execute(name: 'success') + + expect(result[:status]).to eq(:success) + expect(result[:message]).to be_nil + end + + it 'returns agent values', :aggregate_failures do + new_agent = service.execute(name: 'new-agent')[:cluster_agent] + + expect(new_agent.name).to eq('new-agent') + expect(new_agent.created_by_user).to eq(user) + end + + it 'generates an error message when name is invalid' do + expect(service.execute(name: '@bad_agent_name!')).to eq({ + status: :error, + message: ["Name can contain only lowercase letters, digits, and '-', but cannot start or end with '-'"] + }) + end + end + end +end diff --git a/spec/services/clusters/agents/delete_service_spec.rb b/spec/services/clusters/agents/delete_service_spec.rb new file mode 100644 index 00000000000..1d6bc9618dd --- /dev/null +++ b/spec/services/clusters/agents/delete_service_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::DeleteService do + subject(:service) { described_class.new(container: project, current_user: user) } + + let(:cluster_agent) { create(:cluster_agent) } + let(:project) { cluster_agent.project } + let(:user) { create(:user) } + + describe '#execute' do + context 'without user permissions' do + it 'fails to delete when the user has no permissions', :aggregate_failures do + response = service.execute(cluster_agent) + + expect(response.status).to eq(:error) + expect(response.message).to eq('You have insufficient permissions to delete this cluster agent') + + expect { cluster_agent.reload }.not_to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'with user permissions' do + before do + project.add_maintainer(user) + end + + it 'deletes a cluster agent', :aggregate_failures do + expect { service.execute(cluster_agent) }.to change { ::Clusters::Agent.count }.by(-1) + expect { cluster_agent.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/services/concerns/rate_limited_service_spec.rb b/spec/services/concerns/rate_limited_service_spec.rb new file mode 100644 index 00000000000..f73871b7e44 --- /dev/null +++ b/spec/services/concerns/rate_limited_service_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RateLimitedService do + let(:key) { :issues_create } + let(:scope) { [:project, :current_user] } + let(:opts) { { scope: scope, users_allowlist: -> { [User.support_bot.username] } } } + let(:rate_limiter_klass) { ::Gitlab::ApplicationRateLimiter } + let(:rate_limiter_instance) { rate_limiter_klass.new(key, **opts) } + + describe 'RateLimitedError' do + subject { described_class::RateLimitedError.new(key: key, rate_limiter: rate_limiter_instance) } + + describe '#headers' do + it 'returns a Hash of HTTP headers' do + # TODO: This will be fleshed out in https://gitlab.com/gitlab-org/gitlab/-/issues/342370 + expected_headers = {} + + expect(subject.headers).to eq(expected_headers) + end + end + + describe '#log_request' do + it 'logs the request' do + request = instance_double(Grape::Request) + user = instance_double(User) + + expect(rate_limiter_klass).to receive(:log_request).with(request, "#{key}_request_limit".to_sym, user) + + subject.log_request(request, user) + end + end + end + + describe 'RateLimiterScopedAndKeyed' do + subject { described_class::RateLimiterScopedAndKeyed.new(key: key, opts: opts, rate_limiter_klass: rate_limiter_klass) } + + describe '#rate_limit!' do + let(:project_with_feature_enabled) { create(:project) } + let(:project_without_feature_enabled) { create(:project) } + + let(:project) { nil } + + let(:current_user) { create(:user) } + let(:service) { instance_double(Issues::CreateService, project: project, current_user: current_user) } + let(:evaluated_scope) { [project, current_user] } + let(:evaluated_opts) { { scope: evaluated_scope, users_allowlist: %w[support-bot] } } + let(:rate_limited_service_issues_create_feature_enabled) { nil } + + before do + allow(rate_limiter_klass).to receive(:new).with(key, **evaluated_opts).and_return(rate_limiter_instance) + stub_feature_flags(rate_limited_service_issues_create: rate_limited_service_issues_create_feature_enabled) + end + + shared_examples 'a service that does not attempt to throttle' do + it 'does not attempt to throttle' do + expect(rate_limiter_instance).not_to receive(:throttled?) + + expect(subject.rate_limit!(service)).to be_nil + end + end + + shared_examples 'a service that does attempt to throttle' do + before do + allow(rate_limiter_instance).to receive(:throttled?).and_return(throttled) + end + + context 'when rate limiting is not in effect' do + let(:throttled) { false } + + it 'does not raise an exception' do + expect(subject.rate_limit!(service)).to be_nil + end + end + + context 'when rate limiting is in effect' do + let(:throttled) { true } + + it 'raises a RateLimitedError exception' do + expect { subject.rate_limit!(service) }.to raise_error(described_class::RateLimitedError, 'This endpoint has been requested too many times. Try again later.') + end + end + end + + context 'when :rate_limited_service_issues_create feature is globally disabled' do + let(:rate_limited_service_issues_create_feature_enabled) { false } + + it_behaves_like 'a service that does not attempt to throttle' + end + + context 'when :rate_limited_service_issues_create feature is globally enabled' do + let(:throttled) { nil } + let(:rate_limited_service_issues_create_feature_enabled) { true } + let(:project) { project_without_feature_enabled } + + it_behaves_like 'a service that does attempt to throttle' + end + + context 'when :rate_limited_service_issues_create feature is enabled for project_with_feature_enabled' do + let(:throttled) { nil } + let(:rate_limited_service_issues_create_feature_enabled) { project_with_feature_enabled } + + context 'for project_without_feature_enabled' do + let(:project) { project_without_feature_enabled } + + it_behaves_like 'a service that does not attempt to throttle' + end + + context 'for project_with_feature_enabled' do + let(:project) { project_with_feature_enabled } + + it_behaves_like 'a service that does attempt to throttle' + end + end + end + end + + describe '#execute_without_rate_limiting' do + let(:rate_limiter_scoped_and_keyed) { instance_double(RateLimitedService::RateLimiterScopedAndKeyed) } + let(:subject) do + local_key = key + local_opts = opts + + Class.new do + prepend RateLimitedService + + rate_limit key: local_key, opts: local_opts + + def execute(*args, **kwargs) + 'main logic here' + end + end.new + end + + before do + allow(RateLimitedService::RateLimiterScopedAndKeyed).to receive(:new).with(key: key, opts: opts, rate_limiter_klass: rate_limiter_klass).and_return(rate_limiter_scoped_and_keyed) + end + + context 'bypasses rate limiting' do + it 'calls super' do + expect(rate_limiter_scoped_and_keyed).not_to receive(:rate_limit!).with(subject) + + expect(subject.execute_without_rate_limiting).to eq('main logic here') + end + end + end + + describe '#execute' do + context 'when rate_limit has not been called' do + let(:subject) { Class.new { prepend RateLimitedService }.new } + + it 'raises an RateLimitedNotSetupError exception' do + expect { subject.execute }.to raise_error(described_class::RateLimitedNotSetupError) + end + end + + context 'when rate_limit has been called' do + let(:rate_limiter_scoped_and_keyed) { instance_double(RateLimitedService::RateLimiterScopedAndKeyed) } + let(:subject) do + local_key = key + local_opts = opts + + Class.new do + prepend RateLimitedService + + rate_limit key: local_key, opts: local_opts + + def execute(*args, **kwargs) + 'main logic here' + end + end.new + end + + before do + allow(RateLimitedService::RateLimiterScopedAndKeyed).to receive(:new).with(key: key, opts: opts, rate_limiter_klass: rate_limiter_klass).and_return(rate_limiter_scoped_and_keyed) + end + + context 'and applies rate limiting' do + it 'raises an RateLimitedService::RateLimitedError exception' do + expect(rate_limiter_scoped_and_keyed).to receive(:rate_limit!).with(subject).and_raise(RateLimitedService::RateLimitedError.new(key: key, rate_limiter: rate_limiter_instance)) + + expect { subject.execute }.to raise_error(RateLimitedService::RateLimitedError) + end + end + + context 'but does not apply rate limiting' do + it 'calls super' do + expect(rate_limiter_scoped_and_keyed).to receive(:rate_limit!).with(subject).and_return(nil) + + expect(subject.execute).to eq('main logic here') + end + end + end + end +end diff --git a/spec/services/container_expiration_policies/cleanup_service_spec.rb b/spec/services/container_expiration_policies/cleanup_service_spec.rb index 5f284b9dd8b..a1f76e5e5dd 100644 --- a/spec/services/container_expiration_policies/cleanup_service_spec.rb +++ b/spec/services/container_expiration_policies/cleanup_service_spec.rb @@ -24,8 +24,8 @@ RSpec.describe ContainerExpirationPolicies::CleanupService do it 'completely clean up the repository' do expect(Projects::ContainerRepository::CleanupTagsService) - .to receive(:new).with(project, nil, cleanup_tags_service_params).and_return(cleanup_tags_service) - expect(cleanup_tags_service).to receive(:execute).with(repository).and_return(status: :success) + .to receive(:new).with(repository, nil, cleanup_tags_service_params).and_return(cleanup_tags_service) + expect(cleanup_tags_service).to receive(:execute).and_return(status: :success) response = subject @@ -69,6 +69,7 @@ RSpec.describe ContainerExpirationPolicies::CleanupService do before_truncate_size: 800, after_truncate_size: 200, before_delete_size: 100, + cached_tags_count: 0, deleted_size: 100 } end @@ -86,6 +87,7 @@ RSpec.describe ContainerExpirationPolicies::CleanupService do cleanup_tags_service_before_truncate_size: 800, cleanup_tags_service_after_truncate_size: 200, cleanup_tags_service_before_delete_size: 100, + cleanup_tags_service_cached_tags_count: 0, cleanup_tags_service_deleted_size: 100 ) expect(ContainerRepository.waiting_for_cleanup.count).to eq(1) diff --git a/spec/services/customer_relations/contacts/create_service_spec.rb b/spec/services/customer_relations/contacts/create_service_spec.rb new file mode 100644 index 00000000000..71eb447055e --- /dev/null +++ b/spec/services/customer_relations/contacts/create_service_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CustomerRelations::Contacts::CreateService do + describe '#execute' do + let_it_be(:user) { create(:user) } + let_it_be(:not_found_or_does_not_belong) { 'The specified organization was not found or does not belong to this group' } + + let(:params) { attributes_for(:contact, group: group) } + + subject(:response) { described_class.new(group: group, current_user: user, params: params).execute } + + context 'when user does not have permission' do + let_it_be(:group) { create(:group) } + + before_all do + group.add_reporter(user) + end + + it 'returns an error' do + expect(response).to be_error + expect(response.message).to match_array(['You have insufficient permissions to create a contact for this group']) + end + end + + context 'when user has permission' do + let_it_be(:group) { create(:group) } + + before_all do + group.add_developer(user) + end + + it 'creates a contact' do + expect(response).to be_success + end + + it 'returns an error when the contact is not persisted' do + params[:last_name] = nil + + expect(response).to be_error + expect(response.message).to match_array(["Last name can't be blank"]) + end + + it 'returns an error when the organization_id is invalid' do + params[:organization_id] = non_existing_record_id + + expect(response).to be_error + expect(response.message).to match_array([not_found_or_does_not_belong]) + end + + it 'returns an error when the organization belongs to a different group' do + organization = create(:organization) + params[:organization_id] = organization.id + + expect(response).to be_error + expect(response.message).to match_array([not_found_or_does_not_belong]) + end + end + end +end diff --git a/spec/services/customer_relations/contacts/update_service_spec.rb b/spec/services/customer_relations/contacts/update_service_spec.rb new file mode 100644 index 00000000000..7c5fbabb600 --- /dev/null +++ b/spec/services/customer_relations/contacts/update_service_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CustomerRelations::Contacts::UpdateService do + let_it_be(:user) { create(:user) } + + let(:contact) { create(:contact, first_name: 'Mark', group: group) } + + subject(:update) { described_class.new(group: group, current_user: user, params: params).execute(contact) } + + describe '#execute' do + context 'when the user has no permission' do + let_it_be(:group) { create(:group) } + + let(:params) { { first_name: 'Gary' } } + + it 'returns an error' do + response = update + + expect(response).to be_error + expect(response.message).to match_array(['You have insufficient permissions to update a contact for this group']) + end + end + + context 'when user has permission' do + let_it_be(:group) { create(:group) } + + before_all do + group.add_developer(user) + end + + context 'when first_name is changed' do + let(:params) { { first_name: 'Gary' } } + + it 'updates the contact' do + response = update + + expect(response).to be_success + expect(response.payload.first_name).to eq('Gary') + end + end + + context 'when the contact is invalid' do + let(:params) { { first_name: nil } } + + it 'returns an error' do + response = update + + expect(response).to be_error + expect(response.message).to match_array(["First name can't be blank"]) + end + end + end + end +end diff --git a/spec/services/customer_relations/organizations/create_service_spec.rb b/spec/services/customer_relations/organizations/create_service_spec.rb index b4764f6b97a..d8985d8d90b 100644 --- a/spec/services/customer_relations/organizations/create_service_spec.rb +++ b/spec/services/customer_relations/organizations/create_service_spec.rb @@ -12,22 +12,24 @@ RSpec.describe CustomerRelations::Organizations::CreateService do subject(:response) { described_class.new(group: group, current_user: user, params: params).execute } it 'creates an organization' do - group.add_reporter(user) + group.add_developer(user) expect(response).to be_success end it 'returns an error when user does not have permission' do + group.add_reporter(user) + expect(response).to be_error - expect(response.message).to eq('You have insufficient permissions to create an organization for this group') + expect(response.message).to match_array(['You have insufficient permissions to create an organization for this group']) end it 'returns an error when the organization is not persisted' do - group.add_reporter(user) + group.add_developer(user) params[:name] = nil expect(response).to be_error - expect(response.message).to eq(["Name can't be blank"]) + expect(response.message).to match_array(["Name can't be blank"]) end end end diff --git a/spec/services/customer_relations/organizations/update_service_spec.rb b/spec/services/customer_relations/organizations/update_service_spec.rb index eb253540863..bc40cb3e8e7 100644 --- a/spec/services/customer_relations/organizations/update_service_spec.rb +++ b/spec/services/customer_relations/organizations/update_service_spec.rb @@ -19,7 +19,7 @@ RSpec.describe CustomerRelations::Organizations::UpdateService do response = update expect(response).to be_error - expect(response.message).to eq('You have insufficient permissions to update an organization for this group') + expect(response.message).to eq(['You have insufficient permissions to update an organization for this group']) end end @@ -27,7 +27,7 @@ RSpec.describe CustomerRelations::Organizations::UpdateService do let_it_be(:group) { create(:group) } before_all do - group.add_reporter(user) + group.add_developer(user) end context 'when name is changed' do diff --git a/spec/services/dependency_proxy/auth_token_service_spec.rb b/spec/services/dependency_proxy/auth_token_service_spec.rb index 6214d75dfa0..c686f57c5cb 100644 --- a/spec/services/dependency_proxy/auth_token_service_spec.rb +++ b/spec/services/dependency_proxy/auth_token_service_spec.rb @@ -4,47 +4,72 @@ require 'spec_helper' RSpec.describe DependencyProxy::AuthTokenService do include DependencyProxyHelpers - describe '.decoded_token_payload' do - let_it_be(:user) { create(:user) } - let_it_be(:token) { build_jwt(user) } + let_it_be(:user) { create(:user) } + let_it_be(:deploy_token) { create(:deploy_token) } - subject { described_class.decoded_token_payload(token.encoded) } + describe '.user_or_deploy_token_from_jwt' do + subject { described_class.user_or_deploy_token_from_jwt(token.encoded) } - it 'returns the user' do - result = subject + shared_examples 'handling token errors' do + context 'with a decoding error' do + before do + allow(JWT).to receive(:decode).and_raise(JWT::DecodeError) + end - expect(result['user_id']).to eq(user.id) - expect(result['deploy_token']).to be_nil - end + it { is_expected.to eq(nil) } + end - context 'with a deploy token' do - let_it_be(:deploy_token) { create(:deploy_token) } - let_it_be(:token) { build_jwt(deploy_token) } + context 'with an immature signature error' do + before do + allow(JWT).to receive(:decode).and_raise(JWT::ImmatureSignature) + end - it 'returns the deploy token' do - result = subject + it { is_expected.to eq(nil) } + end - expect(result['deploy_token']).to eq(deploy_token.token) - expect(result['user_id']).to be_nil + context 'with an expired signature error' do + it 'returns nil' do + travel_to(Time.zone.now + Auth::DependencyProxyAuthenticationService.token_expire_at + 1.minute) do + expect(subject).to eq(nil) + end + end end end - it 'raises an error if the token is expired' do - travel_to(Time.zone.now + Auth::DependencyProxyAuthenticationService.token_expire_at + 1.minute) do - expect { subject }.to raise_error(JWT::ExpiredSignature) + context 'with a user' do + let_it_be(:token) { build_jwt(user) } + + it { is_expected.to eq(user) } + + context 'with an invalid user id' do + let_it_be(:token) { build_jwt { |jwt| jwt['user_id'] = 'this_is_not_a_user_id' } } + + it 'raises an not found error' do + expect { subject }.to raise_error(ActiveRecord::RecordNotFound) + end end + + it_behaves_like 'handling token errors' end - it 'raises an error if decoding fails' do - allow(JWT).to receive(:decode).and_raise(JWT::DecodeError) + context 'with a deploy token' do + let_it_be(:token) { build_jwt(deploy_token) } + + it { is_expected.to eq(deploy_token) } + + context 'with an invalid token' do + let_it_be(:token) { build_jwt { |jwt| jwt['deploy_token'] = 'this_is_not_a_token' } } + + it { is_expected.to eq(nil) } + end - expect { subject }.to raise_error(JWT::DecodeError) + it_behaves_like 'handling token errors' end - it 'raises an error if signature is immature' do - allow(JWT).to receive(:decode).and_raise(JWT::ImmatureSignature) + context 'with an empty token payload' do + let_it_be(:token) { build_jwt(nil) } - expect { subject }.to raise_error(JWT::ImmatureSignature) + it { is_expected.to eq(nil) } end end end diff --git a/spec/services/dependency_proxy/find_or_create_blob_service_spec.rb b/spec/services/dependency_proxy/find_or_create_blob_service_spec.rb index 3fac749be29..20b0546effa 100644 --- a/spec/services/dependency_proxy/find_or_create_blob_service_spec.rb +++ b/spec/services/dependency_proxy/find_or_create_blob_service_spec.rb @@ -4,7 +4,8 @@ require 'spec_helper' RSpec.describe DependencyProxy::FindOrCreateBlobService do include DependencyProxyHelpers - let(:blob) { create(:dependency_proxy_blob) } + let_it_be_with_reload(:blob) { create(:dependency_proxy_blob) } + let(:group) { blob.group } let(:image) { 'alpine' } let(:tag) { '3.9' } @@ -17,11 +18,7 @@ RSpec.describe DependencyProxy::FindOrCreateBlobService do stub_registry_auth(image, token) end - context 'no cache' do - before do - stub_blob_download(image, blob_sha) - end - + shared_examples 'downloads the remote blob' do it 'downloads blob from remote registry if there is no cached one' do expect(subject[:status]).to eq(:success) expect(subject[:blob]).to be_a(DependencyProxy::Blob) @@ -30,15 +27,34 @@ RSpec.describe DependencyProxy::FindOrCreateBlobService do end end + context 'no cache' do + before do + stub_blob_download(image, blob_sha) + end + + it_behaves_like 'downloads the remote blob' + end + context 'cached blob' do let(:blob_sha) { blob.file_name.sub('.gz', '') } it 'uses cached blob instead of downloading one' do + expect { subject }.to change { blob.reload.updated_at } + expect(subject[:status]).to eq(:success) expect(subject[:blob]).to be_a(DependencyProxy::Blob) expect(subject[:blob]).to eq(blob) expect(subject[:from_cache]).to eq true end + + context 'when the cached blob is expired' do + before do + blob.update_column(:status, DependencyProxy::Blob.statuses[:expired]) + stub_blob_download(image, blob_sha) + end + + it_behaves_like 'downloads the remote blob' + end end context 'no such blob exists remotely' do 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 5896aa255f0..b3f88f91289 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 @@ -21,19 +21,19 @@ RSpec.describe DependencyProxy::FindOrCreateManifestService do describe '#execute' do subject { described_class.new(group, image, tag, token).execute } + 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 + expect(subject[:from_cache]).to eq false + end + end + context 'when no manifest exists' do let_it_be(:image) { 'new-image' } - 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 - expect(subject[:from_cache]).to eq false - end - end - context 'successful head request' do before do stub_manifest_head(image, tag, headers: headers) @@ -60,6 +60,8 @@ RSpec.describe DependencyProxy::FindOrCreateManifestService do shared_examples 'using the cached manifest' do it 'uses cached manifest instead of downloading one', :aggregate_failures do + expect { subject }.to change { dependency_proxy_manifest.reload.updated_at } + expect(subject[:status]).to eq(:success) expect(subject[:manifest]).to be_a(DependencyProxy::Manifest) expect(subject[:manifest]).to eq(dependency_proxy_manifest) @@ -87,6 +89,16 @@ RSpec.describe DependencyProxy::FindOrCreateManifestService do end end + context 'when the cached manifest is expired' do + before do + dependency_proxy_manifest.update_column(:status, DependencyProxy::Manifest.statuses[:expired]) + stub_manifest_head(image, tag, headers: headers) + stub_manifest_download(image, tag, headers: headers) + end + + it_behaves_like 'downloading the manifest' + end + context 'failed connection' do before do expect(DependencyProxy::HeadManifestService).to receive(:new).and_raise(Net::OpenTimeout) diff --git a/spec/services/dependency_proxy/group_settings/update_service_spec.rb b/spec/services/dependency_proxy/group_settings/update_service_spec.rb new file mode 100644 index 00000000000..6f8c55daa8d --- /dev/null +++ b/spec/services/dependency_proxy/group_settings/update_service_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::DependencyProxy::GroupSettings::UpdateService do + using RSpec::Parameterized::TableSyntax + + let_it_be_with_reload(:group) { create(:group) } + let_it_be_with_reload(:group_settings) { create(:dependency_proxy_group_setting, group: group) } + let_it_be(:user) { create(:user) } + let_it_be(:params) { { enabled: false } } + + describe '#execute' do + subject { described_class.new(container: group, current_user: user, params: params).execute } + + shared_examples 'updating the dependency proxy group settings' do + it_behaves_like 'updating the dependency proxy group settings attributes', + from: { enabled: true }, + to: { enabled: false } + + it 'returns a success' do + result = subject + + expect(result.payload[:dependency_proxy_setting]).to be_present + expect(result).to be_success + end + end + + shared_examples 'denying access to dependency proxy group settings' do + context 'with existing dependency proxy group settings' do + it 'returns an error' do + result = subject + + expect(result).to have_attributes( + message: 'Access Denied', + status: :error, + http_status: 403 + ) + end + end + end + + where(:user_role, :shared_examples_name) do + :maintainer | 'updating the dependency proxy group settings' + :developer | 'updating the dependency proxy group settings' + :reporter | 'denying access to dependency proxy group settings' + :guest | 'denying access to dependency proxy group settings' + :anonymous | 'denying access to dependency proxy group settings' + end + + with_them do + before do + stub_config(dependency_proxy: { enabled: true }) + group.send("add_#{user_role}", user) unless user_role == :anonymous + end + + it_behaves_like params[:shared_examples_name] + end + end +end diff --git a/spec/services/deployments/older_deployments_drop_service_spec.rb b/spec/services/deployments/older_deployments_drop_service_spec.rb index 6152a95cc3c..e6fd6725d7d 100644 --- a/spec/services/deployments/older_deployments_drop_service_spec.rb +++ b/spec/services/deployments/older_deployments_drop_service_spec.rb @@ -84,7 +84,7 @@ RSpec.describe Deployments::OlderDeploymentsDropService do it 'does not drop an older deployment and tracks the exception' do expect(Gitlab::ErrorTracking).to receive(:track_exception) - .with(kind_of(RuntimeError), subject_id: deployment.id, deployment_id: older_deployment.id) + .with(kind_of(RuntimeError), subject_id: deployment.id, build_id: older_deployment.deployable_id) expect { subject }.not_to change { Ci::Build.failed.count } end diff --git a/spec/services/deployments/update_service_spec.rb b/spec/services/deployments/update_service_spec.rb index 16b24d0dee8..d3840189ba4 100644 --- a/spec/services/deployments/update_service_spec.rb +++ b/spec/services/deployments/update_service_spec.rb @@ -34,9 +34,11 @@ RSpec.describe Deployments::UpdateService do expect(deploy).to be_canceled end - it 'raises ArgumentError if the status is invalid' do - expect { described_class.new(deploy, status: 'kittens').execute } - .to raise_error(ArgumentError) + it 'does not change the state if the status is invalid' do + expect(described_class.new(deploy, status: 'kittens').execute) + .to be_falsy + + expect(deploy).to be_created end it 'links merge requests when changing the status to success', :sidekiq_inline do diff --git a/spec/services/error_tracking/list_issues_service_spec.rb b/spec/services/error_tracking/list_issues_service_spec.rb index b49095ab8b9..a7bd6c75df5 100644 --- a/spec/services/error_tracking/list_issues_service_spec.rb +++ b/spec/services/error_tracking/list_issues_service_spec.rb @@ -5,56 +5,71 @@ require 'spec_helper' RSpec.describe ErrorTracking::ListIssuesService do include_context 'sentry error tracking context' - let(:params) { { search_term: 'something', sort: 'last_seen', cursor: 'some-cursor' } } - let(:list_sentry_issues_args) do - { - issue_status: 'unresolved', - limit: 20, - search_term: 'something', - sort: 'last_seen', - cursor: 'some-cursor' - } - end + let(:params) { {} } subject { described_class.new(project, user, params) } describe '#execute' do - context 'with authorized user' do - let(:issues) { [] } + context 'Sentry backend' do + let(:params) { { search_term: 'something', sort: 'last_seen', cursor: 'some-cursor' } } + + let(:list_sentry_issues_args) do + { + issue_status: 'unresolved', + limit: 20, + search_term: 'something', + sort: 'last_seen', + cursor: 'some-cursor' + } + end + + context 'with authorized user' do + let(:issues) { [] } + + described_class::ISSUE_STATUS_VALUES.each do |status| + it "returns the issues with #{status} issue_status" do + params[:issue_status] = status + list_sentry_issues_args[:issue_status] = status + expect_list_sentry_issues_with(list_sentry_issues_args) + + expect(result).to eq(status: :success, pagination: {}, issues: issues) + end + end - described_class::ISSUE_STATUS_VALUES.each do |status| - it "returns the issues with #{status} issue_status" do - params[:issue_status] = status - list_sentry_issues_args[:issue_status] = status + it 'returns the issues with no issue_status' do expect_list_sentry_issues_with(list_sentry_issues_args) expect(result).to eq(status: :success, pagination: {}, issues: issues) end - end - it 'returns the issues with no issue_status' do - expect_list_sentry_issues_with(list_sentry_issues_args) + it 'returns bad request for an issue_status not on the whitelist' do + params[:issue_status] = 'assigned' + + expect(error_tracking_setting).not_to receive(:list_sentry_issues) + expect(result).to eq(message: "Bad Request: Invalid issue_status", status: :error, http_status: :bad_request) + end - expect(result).to eq(status: :success, pagination: {}, issues: issues) + include_examples 'error tracking service data not ready', :list_sentry_issues + include_examples 'error tracking service sentry error handling', :list_sentry_issues + include_examples 'error tracking service http status handling', :list_sentry_issues end - it 'returns bad request for an issue_status not on the whitelist' do - params[:issue_status] = 'assigned' + include_examples 'error tracking service unauthorized user' + include_examples 'error tracking service disabled' - expect(error_tracking_setting).not_to receive(:list_sentry_issues) - expect(result).to eq(message: "Bad Request: Invalid issue_status", status: :error, http_status: :bad_request) + def expect_list_sentry_issues_with(list_sentry_issues_args) + expect(error_tracking_setting) + .to receive(:list_sentry_issues) + .with(list_sentry_issues_args) + .and_return(issues: [], pagination: {}) end - - include_examples 'error tracking service data not ready', :list_sentry_issues - include_examples 'error tracking service sentry error handling', :list_sentry_issues - include_examples 'error tracking service http status handling', :list_sentry_issues end - include_examples 'error tracking service unauthorized user' - include_examples 'error tracking service disabled' + context 'GitLab backend' do + let_it_be(:error1) { create(:error_tracking_error, name: 'foo', project: project) } + let_it_be(:error2) { create(:error_tracking_error, name: 'bar', project: project) } - context 'integrated error tracking' do - let_it_be(:error) { create(:error_tracking_error, project: project) } + let(:params) { { limit: '1' } } before do error_tracking_setting.update!(integrated: true) @@ -63,7 +78,9 @@ RSpec.describe ErrorTracking::ListIssuesService do it 'returns the error in expected format' do expect(result[:status]).to eq(:success) expect(result[:issues].size).to eq(1) - expect(result[:issues].first.to_json).to eq(error.to_sentry_error.to_json) + expect(result[:issues].first.to_json).to eq(error2.to_sentry_error.to_json) + expect(result[:pagination][:next][:cursor]).to be_present + expect(result[:pagination][:previous]).to be_nil end end end @@ -76,10 +93,3 @@ RSpec.describe ErrorTracking::ListIssuesService do end end end - -def expect_list_sentry_issues_with(list_sentry_issues_args) - expect(error_tracking_setting) - .to receive(:list_sentry_issues) - .with(list_sentry_issues_args) - .and_return(issues: [], pagination: {}) -end diff --git a/spec/services/feature_flags/hook_service_spec.rb b/spec/services/feature_flags/hook_service_spec.rb new file mode 100644 index 00000000000..02cdbbd86ac --- /dev/null +++ b/spec/services/feature_flags/hook_service_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe FeatureFlags::HookService do + describe '#execute_hooks' do + let_it_be(:namespace) { create(:namespace) } + let_it_be(:project) { create(:project, :repository, namespace: namespace) } + let_it_be(:feature_flag) { create(:operations_feature_flag, project: project) } + let_it_be(:user) { namespace.owner } + + let!(:hook) { create(:project_hook, project: project) } + let(:hook_data) { double } + + subject(:service) { described_class.new(feature_flag, user) } + + describe 'HOOK_NAME' do + specify { expect(described_class::HOOK_NAME).to eq(:feature_flag_hooks) } + end + + before do + allow(Gitlab::DataBuilder::FeatureFlag).to receive(:build).with(feature_flag, user).once.and_return(hook_data) + end + + it 'calls feature_flag.project.execute_hooks' do + expect(feature_flag.project).to receive(:execute_hooks).with(hook_data, described_class::HOOK_NAME) + + service.execute + end + end +end diff --git a/spec/services/groups/transfer_service_spec.rb b/spec/services/groups/transfer_service_spec.rb index 889b5551746..ee38c0fbb44 100644 --- a/spec/services/groups/transfer_service_spec.rb +++ b/spec/services/groups/transfer_service_spec.rb @@ -3,6 +3,19 @@ require 'spec_helper' RSpec.describe Groups::TransferService do + shared_examples 'project namespace path is in sync with project path' do + it 'keeps project and project namespace attributes in sync' do + projects_with_project_namespace.each do |project| + project.reload + + expect(project.full_path).to eq("#{group_full_path}/#{project.path}") + expect(project.project_namespace.full_path).to eq(project.full_path) + expect(project.project_namespace.parent).to eq(project.namespace) + expect(project.project_namespace.visibility_level).to eq(project.visibility_level) + end + end + end + let_it_be(:user) { create(:user) } let_it_be(:new_parent_group) { create(:group, :public) } @@ -169,6 +182,18 @@ RSpec.describe Groups::TransferService do expect(project.full_path).to eq("#{group.path}/#{project.path}") end end + + context 'when projects have project namespaces' do + let_it_be(:project1) { create(:project, :private, namespace: group) } + let_it_be(:project_namespace1) { create(:project_namespace, project: project1) } + let_it_be(:project2) { create(:project, :private, namespace: group) } + let_it_be(:project_namespace2) { create(:project_namespace, project: project2) } + + it_behaves_like 'project namespace path is in sync with project path' do + let(:group_full_path) { "#{group.path}" } + let(:projects_with_project_namespace) { [project1, project2] } + end + end end end @@ -222,10 +247,10 @@ RSpec.describe Groups::TransferService do context 'when the parent group has a project with the same path' do let_it_be_with_reload(:group) { create(:group, :public, :nested, path: 'foo') } + let_it_be(:membership) { create(:group_member, :owner, group: new_parent_group, user: user) } + let_it_be(:project) { create(:project, path: 'foo', namespace: new_parent_group) } before do - create(:group_member, :owner, group: new_parent_group, user: user) - create(:project, path: 'foo', namespace: new_parent_group) group.update_attribute(:path, 'foo') end @@ -237,6 +262,19 @@ RSpec.describe Groups::TransferService do transfer_service.execute(new_parent_group) expect(transfer_service.error).to eq('Transfer failed: Validation failed: Group URL has already been taken') end + + context 'when projects have project namespaces' do + let!(:project_namespace) { create(:project_namespace, project: project) } + + before do + transfer_service.execute(new_parent_group) + end + + it_behaves_like 'project namespace path is in sync with project path' do + let(:group_full_path) { "#{new_parent_group.full_path}" } + let(:projects_with_project_namespace) { [project] } + end + end end context 'when the group is allowed to be transferred' do @@ -324,7 +362,7 @@ RSpec.describe Groups::TransferService do let(:new_parent_group) { create(:group, shared_runners_enabled: false, allow_descendants_override_disabled_shared_runners: true) } it 'calls update service' do - expect(Groups::UpdateSharedRunnersService).to receive(:new).with(group, user, { shared_runners_setting: 'disabled_with_override' }).and_call_original + expect(Groups::UpdateSharedRunnersService).to receive(:new).with(group, user, { shared_runners_setting: Namespace::SR_DISABLED_WITH_OVERRIDE }).and_call_original transfer_service.execute(new_parent_group) end @@ -334,7 +372,7 @@ RSpec.describe Groups::TransferService do let(:new_parent_group) { create(:group, shared_runners_enabled: false, allow_descendants_override_disabled_shared_runners: false) } it 'calls update service' do - expect(Groups::UpdateSharedRunnersService).to receive(:new).with(group, user, { shared_runners_setting: 'disabled_and_unoverridable' }).and_call_original + expect(Groups::UpdateSharedRunnersService).to receive(:new).with(group, user, { shared_runners_setting: Namespace::SR_DISABLED_AND_UNOVERRIDABLE }).and_call_original transfer_service.execute(new_parent_group) end @@ -407,6 +445,8 @@ RSpec.describe Groups::TransferService do context 'when transferring a group with project descendants' do let!(:project1) { create(:project, :repository, :private, namespace: group) } let!(:project2) { create(:project, :repository, :internal, namespace: group) } + let!(:project_namespace1) { create(:project_namespace, project: project1) } + let!(:project_namespace2) { create(:project_namespace, project: project2) } before do TestEnv.clean_test_path @@ -432,18 +472,30 @@ RSpec.describe Groups::TransferService do expect(project1.private?).to be_truthy expect(project2.internal?).to be_truthy end + + it_behaves_like 'project namespace path is in sync with project path' do + let(:group_full_path) { "#{new_parent_group.path}/#{group.path}" } + let(:projects_with_project_namespace) { [project1, project2] } + end end context 'when the new parent has a lower visibility than the projects' do let!(:project1) { create(:project, :repository, :public, namespace: group) } let!(:project2) { create(:project, :repository, :public, namespace: group) } - let(:new_parent_group) { create(:group, :private) } + let!(:new_parent_group) { create(:group, :private) } + let!(:project_namespace1) { create(:project_namespace, project: project1) } + let!(:project_namespace2) { create(:project_namespace, project: project2) } it 'updates projects visibility to match the new parent' do group.projects.each do |project| expect(project.private?).to be_truthy end end + + it_behaves_like 'project namespace path is in sync with project path' do + let(:group_full_path) { "#{new_parent_group.path}/#{group.path}" } + let(:projects_with_project_namespace) { [project1, project2] } + end end end @@ -452,6 +504,8 @@ RSpec.describe Groups::TransferService do let!(:project2) { create(:project, :repository, :internal, namespace: group) } let!(:subgroup1) { create(:group, :private, parent: group) } let!(:subgroup2) { create(:group, :internal, parent: group) } + let!(:project_namespace1) { create(:project_namespace, project: project1) } + let!(:project_namespace2) { create(:project_namespace, project: project2) } before do TestEnv.clean_test_path @@ -480,6 +534,11 @@ RSpec.describe Groups::TransferService do expect(project1.redirect_routes.count).to eq(1) expect(project2.redirect_routes.count).to eq(1) end + + it_behaves_like 'project namespace path is in sync with project path' do + let(:group_full_path) { "#{new_parent_group.path}/#{group.path}" } + let(:projects_with_project_namespace) { [project1, project2] } + end end context 'when transferring a group with nested groups and projects' do @@ -651,6 +710,30 @@ RSpec.describe Groups::TransferService do expect(project1.public?).to be_truthy end end + + context 'when group has pending builds' do + let_it_be(:project) { create(:project, :public, namespace: group.reload) } + let_it_be(:other_project) { create(:project) } + let_it_be(:pending_build) { create(:ci_pending_build, project: project) } + let_it_be(:unrelated_pending_build) { create(:ci_pending_build, project: other_project) } + + before do + group.add_owner(user) + new_parent_group.add_owner(user) + end + + it 'updates pending builds for the group', :aggregate_failures do + transfer_service.execute(new_parent_group) + + pending_build.reload + unrelated_pending_build.reload + + expect(pending_build.namespace_id).to eq(group.id) + expect(pending_build.namespace_traversal_ids).to eq(group.traversal_ids) + expect(unrelated_pending_build.namespace_id).to eq(other_project.namespace_id) + expect(unrelated_pending_build.namespace_traversal_ids).to eq(other_project.namespace.traversal_ids) + end + end end context 'when transferring a subgroup into root group' do diff --git a/spec/services/groups/update_service_spec.rb b/spec/services/groups/update_service_spec.rb index bc7c066fa04..e1bd3732820 100644 --- a/spec/services/groups/update_service_spec.rb +++ b/spec/services/groups/update_service_spec.rb @@ -287,7 +287,7 @@ RSpec.describe Groups::UpdateService do let(:group) { create(:group) } let(:project) { create(:project, shared_runners_enabled: true, group: group) } - subject { described_class.new(group, user, shared_runners_setting: 'disabled_and_unoverridable').execute } + subject { described_class.new(group, user, shared_runners_setting: Namespace::SR_DISABLED_AND_UNOVERRIDABLE).execute } before do group.add_owner(user) diff --git a/spec/services/groups/update_shared_runners_service_spec.rb b/spec/services/groups/update_shared_runners_service_spec.rb index fe18277b5cd..53870e810b1 100644 --- a/spec/services/groups/update_shared_runners_service_spec.rb +++ b/spec/services/groups/update_shared_runners_service_spec.rb @@ -85,10 +85,10 @@ RSpec.describe Groups::UpdateSharedRunnersService do context 'disable shared Runners' do let_it_be(:group) { create(:group) } - let(:params) { { shared_runners_setting: 'disabled_and_unoverridable' } } + let(:params) { { shared_runners_setting: Namespace::SR_DISABLED_AND_UNOVERRIDABLE } } it 'receives correct method and succeeds' do - expect(group).to receive(:update_shared_runners_setting!).with('disabled_and_unoverridable') + expect(group).to receive(:update_shared_runners_setting!).with(Namespace::SR_DISABLED_AND_UNOVERRIDABLE) expect(subject[:status]).to eq(:success) end @@ -108,13 +108,13 @@ RSpec.describe Groups::UpdateSharedRunnersService do end context 'allow descendants to override' do - let(:params) { { shared_runners_setting: 'disabled_with_override' } } + let(:params) { { shared_runners_setting: Namespace::SR_DISABLED_WITH_OVERRIDE } } context 'top level group' do let_it_be(:group) { create(:group, :shared_runners_disabled) } it 'receives correct method and succeeds' do - expect(group).to receive(:update_shared_runners_setting!).with('disabled_with_override') + expect(group).to receive(:update_shared_runners_setting!).with(Namespace::SR_DISABLED_WITH_OVERRIDE) expect(subject[:status]).to eq(:success) end diff --git a/spec/services/import/validate_remote_git_endpoint_service_spec.rb b/spec/services/import/validate_remote_git_endpoint_service_spec.rb new file mode 100644 index 00000000000..fbd8a3cb323 --- /dev/null +++ b/spec/services/import/validate_remote_git_endpoint_service_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Import::ValidateRemoteGitEndpointService do + include StubRequests + + let_it_be(:base_url) { 'http://demo.host/path' } + let_it_be(:endpoint_url) { "#{base_url}/info/refs?service=git-upload-pack" } + let_it_be(:error_message) { "#{base_url} is not a valid HTTP Git repository" } + + describe '#execute' do + let(:valid_response) do + { status: 200, + body: '001e# service=git-upload-pack', + headers: { 'Content-Type': 'application/x-git-upload-pack-advertisement' } } + end + + it 'correctly handles URLs with fragment' do + allow(Gitlab::HTTP).to receive(:get) + + described_class.new(url: "#{base_url}#somehash").execute + + expect(Gitlab::HTTP).to have_received(:get).with(endpoint_url, basic_auth: nil, stream_body: true, follow_redirects: false) + end + + context 'when receiving HTTP response' do + subject { described_class.new(url: base_url) } + + it 'returns success when HTTP response is valid and contains correct payload' do + stub_full_request(endpoint_url, method: :get).to_return(valid_response) + + result = subject.execute + + expect(result).to be_a(ServiceResponse) + expect(result.success?).to be(true) + end + + it 'reports error when status code is not 200' do + stub_full_request(endpoint_url, method: :get).to_return(valid_response.merge({ status: 301 })) + + result = subject.execute + + expect(result).to be_a(ServiceResponse) + expect(result.error?).to be(true) + expect(result.message).to eq(error_message) + end + + it 'reports error when invalid URL is provided' do + result = described_class.new(url: 1).execute + + expect(result).to be_a(ServiceResponse) + expect(result.error?).to be(true) + expect(result.message).to eq('1 is not a valid URL') + end + + it 'reports error when required header is missing' do + stub_full_request(endpoint_url, method: :get).to_return(valid_response.merge({ headers: nil })) + + result = subject.execute + + expect(result).to be_a(ServiceResponse) + expect(result.error?).to be(true) + expect(result.message).to eq(error_message) + end + + it 'reports error when body is in invalid format' do + stub_full_request(endpoint_url, method: :get).to_return(valid_response.merge({ body: 'invalid content' })) + + result = subject.execute + + expect(result).to be_a(ServiceResponse) + expect(result.error?).to be(true) + expect(result.message).to eq(error_message) + end + + it 'reports error when exception is raised' do + stub_full_request(endpoint_url, method: :get).to_raise(SocketError.new('dummy message')) + + result = subject.execute + + expect(result).to be_a(ServiceResponse) + expect(result.error?).to be(true) + expect(result.message).to eq(error_message) + end + end + + it 'passes basic auth when credentials are provided' do + allow(Gitlab::HTTP).to receive(:get) + + described_class.new(url: "#{base_url}#somehash", user: 'user', password: 'password').execute + + expect(Gitlab::HTTP).to have_received(:get).with(endpoint_url, basic_auth: { username: 'user', password: 'password' }, stream_body: true, follow_redirects: false) + end + end +end diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb index 14e6b44f7b0..93ef046a632 100644 --- a/spec/services/issues/close_service_spec.rb +++ b/spec/services/issues/close_service_spec.rb @@ -22,6 +22,18 @@ RSpec.describe Issues::CloseService do describe '#execute' do let(:service) { described_class.new(project: project, current_user: user) } + context 'when skip_authorization is true' do + it 'does close the issue even if user is not authorized' do + non_authorized_user = create(:user) + + service = described_class.new(project: project, current_user: non_authorized_user) + + expect do + service.execute(issue, skip_authorization: true) + end.to change { issue.reload.state }.from('opened').to('closed') + end + end + it 'checks if the user is authorized to update the issue' do expect(service).to receive(:can?).with(user, :update_issue, issue) .and_call_original @@ -156,7 +168,7 @@ RSpec.describe Issues::CloseService do context 'updating `metrics.first_mentioned_in_commit_at`' do context 'when `metrics.first_mentioned_in_commit_at` is not set' do it 'uses the first commit authored timestamp' do - expected = closing_merge_request.commits.first.authored_date + expected = closing_merge_request.commits.take(100).last.authored_date close_issue diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 3988069d83a..1887be4896e 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -10,6 +10,25 @@ RSpec.describe Issues::CreateService do let(:spam_params) { double } + describe '.rate_limiter_scoped_and_keyed' do + it 'is set via the rate_limit call' do + expect(described_class.rate_limiter_scoped_and_keyed).to be_a(RateLimitedService::RateLimiterScopedAndKeyed) + + expect(described_class.rate_limiter_scoped_and_keyed.key).to eq(:issues_create) + expect(described_class.rate_limiter_scoped_and_keyed.opts[:scope]).to eq(%i[project current_user]) + expect(described_class.rate_limiter_scoped_and_keyed.opts[:users_allowlist].call).to eq(%w[support-bot]) + expect(described_class.rate_limiter_scoped_and_keyed.rate_limiter_klass).to eq(Gitlab::ApplicationRateLimiter) + end + end + + describe '#rate_limiter_bypassed' do + let(:subject) { described_class.new(project: project, spam_params: {}) } + + it 'is nil by default' do + expect(subject.rate_limiter_bypassed).to be_nil + end + end + describe '#execute' do let_it_be(:assignee) { create(:user) } let_it_be(:milestone) { create(:milestone, project: project) } diff --git a/spec/services/issues/relative_position_rebalancing_service_spec.rb b/spec/services/issues/relative_position_rebalancing_service_spec.rb index d5d81770817..20064bd7e4b 100644 --- a/spec/services/issues/relative_position_rebalancing_service_spec.rb +++ b/spec/services/issues/relative_position_rebalancing_service_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Issues::RelativePositionRebalancingService, :clean_gitlab_redis_shared_state do - let_it_be(:project, reload: true) { create(:project) } + let_it_be(:project, reload: true) { create(:project, :repository_disabled, skip_disk_validation: true) } let_it_be(:user) { project.creator } let_it_be(:start) { RelativePositioning::START_POSITION } let_it_be(:max_pos) { RelativePositioning::MAX_POSITION } @@ -28,12 +28,18 @@ RSpec.describe Issues::RelativePositionRebalancingService, :clean_gitlab_redis_s end end + let_it_be(:nil_clump, reload: true) do + (1..100).to_a.map do |i| + create(:issue, project: project, author: user, relative_position: nil) + end + end + before do stub_feature_flags(issue_rebalancing_with_retry: false) end def issues_in_position_order - project.reload.issues.reorder(relative_position: :asc).to_a + project.reload.issues.order_by_relative_position.to_a end subject(:service) { described_class.new(Project.id_in(project)) } @@ -44,16 +50,19 @@ RSpec.describe Issues::RelativePositionRebalancingService, :clean_gitlab_redis_s expect { service.execute }.not_to change { issues_in_position_order.map(&:id) } + caching = service.send(:caching) all_issues.each(&:reset) gaps = all_issues.take(all_issues.count - 1).zip(all_issues.drop(1)).map do |a, b| b.relative_position - a.relative_position end + expect(caching.issue_count).to eq(900) expect(gaps).to all(be > RelativePositioning::MIN_GAP) expect(all_issues.first.relative_position).to be > (RelativePositioning::MIN_POSITION * 0.9999) expect(all_issues.last.relative_position).to be < (RelativePositioning::MAX_POSITION * 0.9999) expect(project.root_namespace.issue_repositioning_disabled?).to be false + expect(project.issues.with_null_relative_position.count).to eq(100) end it 'is idempotent' do @@ -111,7 +120,7 @@ RSpec.describe Issues::RelativePositionRebalancingService, :clean_gitlab_redis_s allow(caching).to receive(:concurrent_running_rebalances_count).and_return(10) allow(service).to receive(:caching).and_return(caching) - expect { service.execute }.not_to raise_error(Issues::RelativePositionRebalancingService::TooManyConcurrentRebalances) + expect { service.execute }.not_to raise_error end context 're-balancing is retried on statement timeout exceptions' do diff --git a/spec/services/issues/reopen_service_spec.rb b/spec/services/issues/reopen_service_spec.rb index 86190c4e475..c9469b861ac 100644 --- a/spec/services/issues/reopen_service_spec.rb +++ b/spec/services/issues/reopen_service_spec.rb @@ -8,18 +8,26 @@ RSpec.describe Issues::ReopenService do describe '#execute' do context 'when user is not authorized to reopen issue' do - before do + it 'does not reopen the issue' do guest = create(:user) project.add_guest(guest) - perform_enqueued_jobs do - described_class.new(project: project, current_user: guest).execute(issue) - end - end + described_class.new(project: project, current_user: guest).execute(issue) - it 'does not reopen the issue' do expect(issue).to be_closed end + + context 'when skip_authorization is true' do + it 'does close the issue even if user is not authorized' do + non_authorized_user = create(:user) + + service = described_class.new(project: project, current_user: non_authorized_user) + + expect do + service.execute(issue, skip_authorization: true) + end.to change { issue.reload.state }.from('closed').to('opened') + end + end end context 'when user is authorized to reopen issue' do diff --git a/spec/services/members/create_service_spec.rb b/spec/services/members/create_service_spec.rb index 15ed5c5a33f..2e6e6041fc3 100644 --- a/spec/services/members/create_service_spec.rb +++ b/spec/services/members/create_service_spec.rb @@ -80,7 +80,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_ it 'does not add a member' do expect(execute_service[:status]).to eq(:error) - expect(execute_service[:message]).to eq('Invite email has already been taken') + expect(execute_service[:message]).to eq("The member's email address has already been taken") expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(false) end end diff --git a/spec/services/members/invite_service_spec.rb b/spec/services/members/invite_service_spec.rb index dd82facaf14..478733e8aa0 100644 --- a/spec/services/members/invite_service_spec.rb +++ b/spec/services/members/invite_service_spec.rb @@ -150,7 +150,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_ expect_to_create_members(count: 1) expect(result[:status]).to eq(:error) expect(result[:message][invited_member.invite_email]) - .to eq("Invite email has already been taken") + .to eq("The member's email address has already been taken") expect(project.users).to include project_user end end diff --git a/spec/services/merge_requests/assign_issues_service_spec.rb b/spec/services/merge_requests/assign_issues_service_spec.rb index b857f26c052..cf405c0102e 100644 --- a/spec/services/merge_requests/assign_issues_service_spec.rb +++ b/spec/services/merge_requests/assign_issues_service_spec.rb @@ -17,7 +17,7 @@ RSpec.describe MergeRequests::AssignIssuesService do expect(service.assignable_issues.map(&:id)).to include(issue.id) end - it 'ignores issues the user cannot update assignee on' do + it 'ignores issues the user cannot update assignee on', :sidekiq_inline do project.team.truncate expect(service.assignable_issues).to be_empty diff --git a/spec/services/merge_requests/build_service_spec.rb b/spec/services/merge_requests/build_service_spec.rb index 0f282384661..ab3d9880d29 100644 --- a/spec/services/merge_requests/build_service_spec.rb +++ b/spec/services/merge_requests/build_service_spec.rb @@ -440,7 +440,7 @@ RSpec.describe MergeRequests::BuildService 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 + it 'adds the remaining lines of the first multi-line commit message as the description', :sidekiq_inline do expect(merge_request.description).to eq('Create the app') end end diff --git a/spec/services/merge_requests/mergeability/check_base_service_spec.rb b/spec/services/merge_requests/mergeability/check_base_service_spec.rb new file mode 100644 index 00000000000..f07522b43cb --- /dev/null +++ b/spec/services/merge_requests/mergeability/check_base_service_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequests::Mergeability::CheckBaseService do + subject(:check_base_service) { described_class.new(merge_request: merge_request, params: params) } + + let(:merge_request) { double } + let(:params) { double } + + describe '#merge_request' do + it 'returns the merge_request' do + expect(check_base_service.merge_request).to eq merge_request + end + end + + describe '#params' do + it 'returns the params' do + expect(check_base_service.params).to eq params + end + end + + describe '#skip?' do + it 'raises NotImplementedError' do + expect { check_base_service.skip? }.to raise_error(NotImplementedError) + end + end + + describe '#cacheable?' do + it 'raises NotImplementedError' do + expect { check_base_service.skip? }.to raise_error(NotImplementedError) + end + end + + describe '#cache_key?' do + it 'raises NotImplementedError' do + expect { check_base_service.skip? }.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/services/merge_requests/mergeability/check_ci_status_service_spec.rb b/spec/services/merge_requests/mergeability/check_ci_status_service_spec.rb new file mode 100644 index 00000000000..6fbbecd7c0e --- /dev/null +++ b/spec/services/merge_requests/mergeability/check_ci_status_service_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequests::Mergeability::CheckCiStatusService do + subject(:check_ci_status) { described_class.new(merge_request: merge_request, params: params) } + + let(:merge_request) { build(:merge_request) } + let(:params) { { skip_ci_check: skip_check } } + let(:skip_check) { false } + + describe '#execute' do + before do + expect(merge_request).to receive(:mergeable_ci_state?).and_return(mergeable) + end + + context 'when the merge request is in a mergable state' do + let(:mergeable) { true } + + it 'returns a check result with status success' do + expect(check_ci_status.execute.status).to eq Gitlab::MergeRequests::Mergeability::CheckResult::SUCCESS_STATUS + end + end + + context 'when the merge request is not in a mergeable state' do + let(:mergeable) { false } + + it 'returns a check result with status failed' do + expect(check_ci_status.execute.status).to eq Gitlab::MergeRequests::Mergeability::CheckResult::FAILED_STATUS + end + end + end + + describe '#skip?' do + context 'when skip check is true' do + let(:skip_check) { true } + + it 'returns true' do + expect(check_ci_status.skip?).to eq true + end + end + + context 'when skip check is false' do + let(:skip_check) { false } + + it 'returns false' do + expect(check_ci_status.skip?).to eq false + end + end + end + + describe '#cacheable?' do + it 'returns false' do + expect(check_ci_status.cacheable?).to eq false + end + end +end diff --git a/spec/services/merge_requests/mergeability/run_checks_service_spec.rb b/spec/services/merge_requests/mergeability/run_checks_service_spec.rb new file mode 100644 index 00000000000..170d99f4642 --- /dev/null +++ b/spec/services/merge_requests/mergeability/run_checks_service_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequests::Mergeability::RunChecksService do + subject(:run_checks) { described_class.new(merge_request: merge_request, params: {}) } + + let_it_be(:merge_request) { create(:merge_request) } + + describe '#CHECKS' do + it 'contains every subclass of the base checks service' do + expect(described_class::CHECKS).to contain_exactly(*MergeRequests::Mergeability::CheckBaseService.subclasses) + end + end + + describe '#execute' do + subject(:execute) { run_checks.execute } + + let(:params) { {} } + let(:success_result) { Gitlab::MergeRequests::Mergeability::CheckResult.success } + + context 'when every check is skipped' do + before do + MergeRequests::Mergeability::CheckBaseService.subclasses.each do |subclass| + expect_next_instance_of(subclass) do |service| + expect(service).to receive(:skip?).and_return(true) + end + end + end + + it 'is still a success' do + expect(execute.all?(&:success?)).to eq(true) + end + end + + context 'when a check is skipped' do + it 'does not execute the check' do + expect_next_instance_of(MergeRequests::Mergeability::CheckCiStatusService) do |service| + expect(service).to receive(:skip?).and_return(true) + expect(service).not_to receive(:execute) + end + + expect(execute).to match_array([]) + end + end + + context 'when a check is not skipped' do + let(:cacheable) { true } + let(:merge_check) { instance_double(MergeRequests::Mergeability::CheckCiStatusService) } + + before do + expect(MergeRequests::Mergeability::CheckCiStatusService).to receive(:new).and_return(merge_check) + expect(merge_check).to receive(:skip?).and_return(false) + allow(merge_check).to receive(:cacheable?).and_return(cacheable) + allow(merge_check).to receive(:execute).and_return(success_result) + end + + context 'when the check is cacheable' do + context 'when the check is cached' do + it 'returns the cached result' do + expect_next_instance_of(Gitlab::MergeRequests::Mergeability::ResultsStore) do |service| + expect(service).to receive(:read).with(merge_check: merge_check).and_return(success_result) + end + + expect(execute).to match_array([success_result]) + end + end + + context 'when the check is not cached' do + it 'writes and returns the result' do + expect_next_instance_of(Gitlab::MergeRequests::Mergeability::ResultsStore) do |service| + expect(service).to receive(:read).with(merge_check: merge_check).and_return(nil) + expect(service).to receive(:write).with(merge_check: merge_check, result_hash: success_result.to_hash).and_return(true) + end + + expect(execute).to match_array([success_result]) + end + end + end + + context 'when check is not cacheable' do + let(:cacheable) { false } + + it 'does not call the results store' do + expect(Gitlab::MergeRequests::Mergeability::ResultsStore).not_to receive(:new) + + expect(execute).to match_array([success_result]) + end + end + + context 'when mergeability_caching is turned off' do + before do + stub_feature_flags(mergeability_caching: false) + end + + it 'does not call the results store' do + expect(Gitlab::MergeRequests::Mergeability::ResultsStore).not_to receive(:new) + + expect(execute).to match_array([success_result]) + end + end + end + end +end diff --git a/spec/services/merge_requests/push_options_handler_service_spec.rb b/spec/services/merge_requests/push_options_handler_service_spec.rb index f00a8928109..348ea9ad7d4 100644 --- a/spec/services/merge_requests/push_options_handler_service_spec.rb +++ b/spec/services/merge_requests/push_options_handler_service_spec.rb @@ -701,7 +701,7 @@ RSpec.describe MergeRequests::PushOptionsHandlerService do let(:push_options) { { create: true } } let(:changes) { new_branch_changes } - it 'records an error' do + it 'records an error', :sidekiq_inline do Members::DestroyService.new(user1).execute(ProjectMember.find_by!(user_id: user1.id)) service.execute diff --git a/spec/services/notes/quick_actions_service_spec.rb b/spec/services/notes/quick_actions_service_spec.rb index 0a56f01ebba..bca954c3959 100644 --- a/spec/services/notes/quick_actions_service_spec.rb +++ b/spec/services/notes/quick_actions_service_spec.rb @@ -47,7 +47,7 @@ RSpec.describe Notes::QuickActionsService do let(:note_text) { "/relate #{other_issue.to_reference}" } let(:note) { create(:note_on_issue, noteable: issue, project: project, note: note_text) } - context 'user cannot relate issues' do + context 'user cannot relate issues', :sidekiq_inline do before do project.team.find_member(maintainer.id).destroy! project.update!(visibility: Gitlab::VisibilityLevel::PUBLIC) diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index a03f1f17b39..48718cbc24a 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -3155,7 +3155,7 @@ RSpec.describe NotificationService, :mailer do notification.pipeline_finished(pipeline) end - it 'does not send emails' do + it 'does not send emails', :sidekiq_inline do should_not_email_anyone 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 2ffd0a269f2..593777faa55 100644 --- a/spec/services/packages/composer/create_package_service_spec.rb +++ b/spec/services/packages/composer/create_package_service_spec.rb @@ -24,25 +24,6 @@ RSpec.describe Packages::Composer::CreatePackageService do let(:created_package) { Packages::Package.composer.last } - shared_examples 'using the cache update worker' do - context 'with remove_composer_v1_cache_code enabled' do - it 'does not enqueue a cache update job' do - expect(::Packages::Composer::CacheUpdateWorker).not_to receive(:perform_async) - - subject - end - end - - context 'with remove_composer_v1_cache_code disabled' do - it 'enqueues a cache update job' do - stub_feature_flags(remove_composer_v1_cache_code: true) - expect(::Packages::Composer::CacheUpdateWorker).not_to receive(:perform_async) - - subject - end - end - end - context 'without an existing package' do context 'with a branch' do let(:branch) { project.repository.find_branch('master') } @@ -64,7 +45,6 @@ RSpec.describe Packages::Composer::CreatePackageService do it_behaves_like 'assigns build to package' it_behaves_like 'assigns status to package' - it_behaves_like 'using the cache update worker' end context 'with a tag' do @@ -89,7 +69,6 @@ RSpec.describe Packages::Composer::CreatePackageService do it_behaves_like 'assigns build to package' it_behaves_like 'assigns status to package' - it_behaves_like 'using the cache update worker' end end @@ -106,8 +85,6 @@ RSpec.describe Packages::Composer::CreatePackageService do .to change { Packages::Package.composer.count }.by(0) .and change { Packages::Composer::Metadatum.count }.by(0) end - - it_behaves_like 'using the cache update worker' end context 'belonging to another project' do @@ -129,8 +106,6 @@ RSpec.describe Packages::Composer::CreatePackageService do .to change { Packages::Package.composer.count }.by(1) .and change { Packages::Composer::Metadatum.count }.by(1) end - - it_behaves_like 'using the cache update worker' end end end diff --git a/spec/services/packages/debian/process_changes_service_spec.rb b/spec/services/packages/debian/process_changes_service_spec.rb index 3069a2806b2..8e5e36cdbcb 100644 --- a/spec/services/packages/debian/process_changes_service_spec.rb +++ b/spec/services/packages/debian/process_changes_service_spec.rb @@ -36,7 +36,6 @@ RSpec.describe Packages::Debian::ProcessChangesService do .to not_change { Packages::Package.count } .and not_change { Packages::PackageFile.count } .and not_change { incoming.package_files.count } - .and not_change { distribution.reload.needs_update? } .and raise_error(Packages::Debian::ExtractChangesMetadataService::ExtractionError, 'is not a changes file') end end @@ -54,7 +53,6 @@ RSpec.describe Packages::Debian::ProcessChangesService do .to not_change { Packages::Package.count } .and not_change { Packages::PackageFile.count } .and not_change { incoming.package_files.count } - .and not_change { distribution.reload.needs_update? } .and raise_error(ActiveRecord::ConnectionTimeoutError, 'connect timeout') end end diff --git a/spec/services/projects/container_repository/cache_tags_created_at_service_spec.rb b/spec/services/projects/container_repository/cache_tags_created_at_service_spec.rb new file mode 100644 index 00000000000..dfe2ff9e57c --- /dev/null +++ b/spec/services/projects/container_repository/cache_tags_created_at_service_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Projects::ContainerRepository::CacheTagsCreatedAtService, :clean_gitlab_redis_cache do + let_it_be(:dummy_tag_class) { Struct.new(:name, :created_at) } + let_it_be(:repository) { create(:container_repository) } + + let(:tags) { create_tags(5) } + let(:service) { described_class.new(repository) } + + shared_examples 'not interacting with redis' do + it 'does not interact with redis' do + expect(::Gitlab::Redis::Cache).not_to receive(:with) + + subject + end + end + + describe '#populate' do + subject { service.populate(tags) } + + context 'with tags' do + it 'gets values from redis' do + expect(::Gitlab::Redis::Cache).to receive(:with).and_call_original + + expect(subject).to eq(0) + + tags.each { |t| expect(t.created_at).to eq(nil) } + end + + context 'with cached values' do + let(:cached_tags) { tags.first(2) } + + before do + ::Gitlab::Redis::Cache.with do |redis| + cached_tags.each do |tag| + redis.set(cache_key(tag), rfc3339(10.days.ago)) + end + end + end + + it 'gets values from redis' do + expect(::Gitlab::Redis::Cache).to receive(:with).and_call_original + + expect(subject).to eq(2) + + cached_tags.each { |t| expect(t.created_at).not_to eq(nil) } + (tags - cached_tags).each { |t| expect(t.created_at).to eq(nil) } + end + end + end + + context 'with no tags' do + let(:tags) { [] } + + it_behaves_like 'not interacting with redis' + end + end + + describe '#insert' do + let(:max_ttl) { 90.days } + + subject { service.insert(tags, max_ttl) } + + context 'with tags' do + let(:tag) { tags.first } + let(:ttl) { 90.days - 3.days } + + before do + travel_to(Time.zone.local(2021, 9, 2, 12, 0, 0)) + + tag.created_at = DateTime.rfc3339(3.days.ago.rfc3339) + end + + after do + travel_back + end + + it 'inserts values in redis' do + ::Gitlab::Redis::Cache.with do |redis| + expect(redis) + .to receive(:set) + .with(cache_key(tag), rfc3339(tag.created_at), ex: ttl.to_i) + .and_call_original + end + + subject + end + + context 'with some of them already cached' do + let(:tag) { tags.first } + + before do + ::Gitlab::Redis::Cache.with do |redis| + redis.set(cache_key(tag), rfc3339(10.days.ago)) + end + service.populate(tags) + end + + it_behaves_like 'not interacting with redis' + end + end + + context 'with no tags' do + let(:tags) { [] } + + it_behaves_like 'not interacting with redis' + end + + context 'with no expires_in' do + let(:max_ttl) { nil } + + it_behaves_like 'not interacting with redis' + end + end + + def create_tags(size) + Array.new(size) do |i| + dummy_tag_class.new("Tag #{i}", nil) + end + end + + def cache_key(tag) + "container_repository:{#{repository.id}}:tag:#{tag.name}:created_at" + end + + def rfc3339(date_time) + # DateTime rfc3339 is different ActiveSupport::TimeWithZone rfc3339 + # The caching will use DateTime rfc3339 + DateTime.rfc3339(date_time.rfc3339).rfc3339 + end +end diff --git a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb index eed22416868..289bbf4540e 100644 --- a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb +++ b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb @@ -2,14 +2,14 @@ require 'spec_helper' -RSpec.describe Projects::ContainerRepository::CleanupTagsService do +RSpec.describe Projects::ContainerRepository::CleanupTagsService, :clean_gitlab_redis_cache do using RSpec::Parameterized::TableSyntax let_it_be(:user) { create(:user) } let_it_be(:project, reload: true) { create(:project, :private) } - let_it_be(:repository) { create(:container_repository, :root, project: project) } - let(:service) { described_class.new(project, user, params) } + let(:repository) { create(:container_repository, :root, project: project) } + let(:service) { described_class.new(repository, user, params) } let(:tags) { %w[latest A Ba Bb C D E] } before do @@ -39,291 +39,442 @@ RSpec.describe Projects::ContainerRepository::CleanupTagsService do end describe '#execute' do - subject { service.execute(repository) } + subject { service.execute } - context 'when no params are specified' do - let(:params) { {} } + shared_examples 'reading and removing tags' do |caching_enabled: true| + context 'when no params are specified' do + let(:params) { {} } - it 'does not remove anything' do - expect_any_instance_of(Projects::ContainerRepository::DeleteTagsService) - .not_to receive(:execute) + it 'does not remove anything' do + expect_any_instance_of(Projects::ContainerRepository::DeleteTagsService) + .not_to receive(:execute) + expect_no_caching - is_expected.to eq(expected_service_response(before_truncate_size: 0, after_truncate_size: 0, before_delete_size: 0)) - end - end - - context 'when regex matching everything is specified' do - shared_examples 'removes all matches' do - it 'does remove all tags except latest' do - expect_delete(%w(A Ba Bb C D E)) - - is_expected.to eq(expected_service_response(deleted: %w(A Ba Bb C D E))) + is_expected.to eq(expected_service_response(before_truncate_size: 0, after_truncate_size: 0, before_delete_size: 0)) end end - let(:params) do - { 'name_regex_delete' => '.*' } - end + context 'when regex matching everything is specified' do + shared_examples 'removes all matches' do + it 'does remove all tags except latest' do + expect_no_caching - it_behaves_like 'removes all matches' + expect_delete(%w(A Ba Bb C D E)) + + is_expected.to eq(expected_service_response(deleted: %w(A Ba Bb C D E))) + end + end - context 'with deprecated name_regex param' do let(:params) do - { 'name_regex' => '.*' } + { 'name_regex_delete' => '.*' } end it_behaves_like 'removes all matches' + + context 'with deprecated name_regex param' do + let(:params) do + { 'name_regex' => '.*' } + end + + it_behaves_like 'removes all matches' + end end - end - context 'with invalid regular expressions' do - RSpec.shared_examples 'handling an invalid regex' do - it 'keeps all tags' do - expect(Projects::ContainerRepository::DeleteTagsService) - .not_to receive(:new) - subject + context 'with invalid regular expressions' do + shared_examples 'handling an invalid regex' do + it 'keeps all tags' do + expect_no_caching + + expect(Projects::ContainerRepository::DeleteTagsService) + .not_to receive(:new) + + subject + end + + it { is_expected.to eq(status: :error, message: 'invalid regex') } + + it 'calls error tracking service' do + expect(Gitlab::ErrorTracking).to receive(:log_exception).and_call_original + + subject + end end - it { is_expected.to eq(status: :error, message: 'invalid regex') } + context 'when name_regex_delete is invalid' do + let(:params) { { 'name_regex_delete' => '*test*' } } + + it_behaves_like 'handling an invalid regex' + end - it 'calls error tracking service' do - expect(Gitlab::ErrorTracking).to receive(:log_exception).and_call_original + context 'when name_regex is invalid' do + let(:params) { { 'name_regex' => '*test*' } } - subject + it_behaves_like 'handling an invalid regex' end - end - context 'when name_regex_delete is invalid' do - let(:params) { { 'name_regex_delete' => '*test*' } } + context 'when name_regex_keep is invalid' do + let(:params) { { 'name_regex_keep' => '*test*' } } - it_behaves_like 'handling an invalid regex' + it_behaves_like 'handling an invalid regex' + end end - context 'when name_regex is invalid' do - let(:params) { { 'name_regex' => '*test*' } } + context 'when delete regex matching specific tags is used' do + let(:params) do + { 'name_regex_delete' => 'C|D' } + end - it_behaves_like 'handling an invalid regex' - end + it 'does remove C and D' do + expect_delete(%w(C D)) - context 'when name_regex_keep is invalid' do - let(:params) { { 'name_regex_keep' => '*test*' } } + expect_no_caching - it_behaves_like 'handling an invalid regex' - end - end + is_expected.to eq(expected_service_response(deleted: %w(C D), before_truncate_size: 2, after_truncate_size: 2, before_delete_size: 2)) + end - context 'when delete regex matching specific tags is used' do - let(:params) do - { 'name_regex_delete' => 'C|D' } - end + context 'with overriding allow regex' do + let(:params) do + { 'name_regex_delete' => 'C|D', + 'name_regex_keep' => 'C' } + end - it 'does remove C and D' do - expect_delete(%w(C D)) + it 'does not remove C' do + expect_delete(%w(D)) - is_expected.to eq(expected_service_response(deleted: %w(C D), before_truncate_size: 2, after_truncate_size: 2, before_delete_size: 2)) - end + expect_no_caching - context 'with overriding allow regex' do - let(:params) do - { 'name_regex_delete' => 'C|D', - 'name_regex_keep' => 'C' } + is_expected.to eq(expected_service_response(deleted: %w(D), before_truncate_size: 1, after_truncate_size: 1, before_delete_size: 1)) + end end - it 'does not remove C' do - expect_delete(%w(D)) + context 'with name_regex_delete overriding deprecated name_regex' do + let(:params) do + { 'name_regex' => 'C|D', + 'name_regex_delete' => 'D' } + end + + it 'does not remove C' do + expect_delete(%w(D)) + + expect_no_caching - is_expected.to eq(expected_service_response(deleted: %w(D), before_truncate_size: 1, after_truncate_size: 1, before_delete_size: 1)) + is_expected.to eq(expected_service_response(deleted: %w(D), before_truncate_size: 1, after_truncate_size: 1, before_delete_size: 1)) + end end end - context 'with name_regex_delete overriding deprecated name_regex' do + context 'with allow regex value' do let(:params) do - { 'name_regex' => 'C|D', - 'name_regex_delete' => 'D' } + { 'name_regex_delete' => '.*', + 'name_regex_keep' => 'B.*' } end - it 'does not remove C' do - expect_delete(%w(D)) + it 'does not remove B*' do + expect_delete(%w(A C D E)) + + expect_no_caching - is_expected.to eq(expected_service_response(deleted: %w(D), before_truncate_size: 1, after_truncate_size: 1, before_delete_size: 1)) + is_expected.to eq(expected_service_response(deleted: %w(A C D E), before_truncate_size: 4, after_truncate_size: 4, before_delete_size: 4)) end end - end - context 'with allow regex value' do - let(:params) do - { 'name_regex_delete' => '.*', - 'name_regex_keep' => 'B.*' } - end + context 'when keeping only N tags' do + let(:params) do + { 'name_regex' => 'A|B.*|C', + 'keep_n' => 1 } + end - it 'does not remove B*' do - expect_delete(%w(A C D E)) + it 'sorts tags by date' do + expect_delete(%w(Bb Ba C)) - is_expected.to eq(expected_service_response(deleted: %w(A C D E), before_truncate_size: 4, after_truncate_size: 4, before_delete_size: 4)) - end - end + expect_no_caching - context 'when keeping only N tags' do - let(:params) do - { 'name_regex' => 'A|B.*|C', - 'keep_n' => 1 } + expect(service).to receive(:order_by_date).and_call_original + + is_expected.to eq(expected_service_response(deleted: %w(Bb Ba C), before_truncate_size: 4, after_truncate_size: 4, before_delete_size: 3)) + end end - it 'sorts tags by date' do - expect_delete(%w(Bb Ba C)) + context 'when not keeping N tags' do + let(:params) do + { 'name_regex' => 'A|B.*|C' } + end + + it 'does not sort tags by date' do + expect_delete(%w(A Ba Bb C)) - expect(service).to receive(:order_by_date).and_call_original + expect_no_caching - is_expected.to eq(expected_service_response(deleted: %w(Bb Ba C), before_truncate_size: 4, after_truncate_size: 4, before_delete_size: 3)) - end - end + expect(service).not_to receive(:order_by_date) - context 'when not keeping N tags' do - let(:params) do - { 'name_regex' => 'A|B.*|C' } + is_expected.to eq(expected_service_response(deleted: %w(A Ba Bb C), before_truncate_size: 4, after_truncate_size: 4, before_delete_size: 4)) + end end - it 'does not sort tags by date' do - expect_delete(%w(A Ba Bb C)) + context 'when removing keeping only 3' do + let(:params) do + { 'name_regex_delete' => '.*', + 'keep_n' => 3 } + end - expect(service).not_to receive(:order_by_date) + it 'does remove B* and C as they are the oldest' do + expect_delete(%w(Bb Ba C)) - is_expected.to eq(expected_service_response(deleted: %w(A Ba Bb C), before_truncate_size: 4, after_truncate_size: 4, before_delete_size: 4)) - end - end + expect_no_caching - context 'when removing keeping only 3' do - let(:params) do - { 'name_regex_delete' => '.*', - 'keep_n' => 3 } + is_expected.to eq(expected_service_response(deleted: %w(Bb Ba C), before_delete_size: 3)) + end end - it 'does remove B* and C as they are the oldest' do - expect_delete(%w(Bb Ba C)) + context 'when removing older than 1 day' do + let(:params) do + { 'name_regex_delete' => '.*', + 'older_than' => '1 day' } + end + + it 'does remove B* and C as they are older than 1 day' do + expect_delete(%w(Ba Bb C)) - is_expected.to eq(expected_service_response(deleted: %w(Bb Ba C), before_delete_size: 3)) - end - end + expect_no_caching - context 'when removing older than 1 day' do - let(:params) do - { 'name_regex_delete' => '.*', - 'older_than' => '1 day' } + is_expected.to eq(expected_service_response(deleted: %w(Ba Bb C), before_delete_size: 3)) + end end - it 'does remove B* and C as they are older than 1 day' do - expect_delete(%w(Ba Bb C)) + context 'when combining all parameters' do + let(:params) do + { 'name_regex_delete' => '.*', + 'keep_n' => 1, + 'older_than' => '1 day' } + end + + it 'does remove B* and C' do + expect_delete(%w(Bb Ba C)) - is_expected.to eq(expected_service_response(deleted: %w(Ba Bb C), before_delete_size: 3)) - end - end + expect_no_caching - context 'when combining all parameters' do - let(:params) do - { 'name_regex_delete' => '.*', - 'keep_n' => 1, - 'older_than' => '1 day' } + is_expected.to eq(expected_service_response(deleted: %w(Bb Ba C), before_delete_size: 3)) + end end - it 'does remove B* and C' do - expect_delete(%w(Bb Ba C)) + context 'when running a container_expiration_policy' do + let(:user) { nil } - is_expected.to eq(expected_service_response(deleted: %w(Bb Ba C), before_delete_size: 3)) - end - end + context 'with valid container_expiration_policy param' do + let(:params) do + { 'name_regex_delete' => '.*', + 'keep_n' => 1, + 'older_than' => '1 day', + 'container_expiration_policy' => true } + end - context 'when running a container_expiration_policy' do - let(:user) { nil } + it 'succeeds without a user' do + expect_delete(%w(Bb Ba C), container_expiration_policy: true) - context 'with valid container_expiration_policy param' do - let(:params) do - { 'name_regex_delete' => '.*', - 'keep_n' => 1, - 'older_than' => '1 day', - 'container_expiration_policy' => true } + caching_enabled ? expect_caching : expect_no_caching + + is_expected.to eq(expected_service_response(deleted: %w(Bb Ba C), before_delete_size: 3)) + end end - it 'succeeds without a user' do - expect_delete(%w(Bb Ba C), container_expiration_policy: true) + context 'without container_expiration_policy param' do + let(:params) do + { 'name_regex_delete' => '.*', + 'keep_n' => 1, + 'older_than' => '1 day' } + end - is_expected.to eq(expected_service_response(deleted: %w(Bb Ba C), before_delete_size: 3)) + it 'fails' do + is_expected.to eq(status: :error, message: 'access denied') + end end end - context 'without container_expiration_policy param' do + context 'truncating the tags list' do let(:params) do - { 'name_regex_delete' => '.*', - 'keep_n' => 1, - 'older_than' => '1 day' } + { + 'name_regex_delete' => '.*', + 'keep_n' => 1 + } + end + + shared_examples 'returning the response' do |status:, original_size:, before_truncate_size:, after_truncate_size:, before_delete_size:| + it 'returns the response' do + expect_no_caching + + result = subject + + service_response = expected_service_response( + status: status, + original_size: original_size, + before_truncate_size: before_truncate_size, + after_truncate_size: after_truncate_size, + before_delete_size: before_delete_size, + deleted: nil + ) + + expect(result).to eq(service_response) + end + end + + where(:feature_flag_enabled, :max_list_size, :delete_tags_service_status, :expected_status, :expected_truncated) do + false | 10 | :success | :success | false + false | 10 | :error | :error | false + false | 3 | :success | :success | false + false | 3 | :error | :error | false + false | 0 | :success | :success | false + false | 0 | :error | :error | false + true | 10 | :success | :success | false + true | 10 | :error | :error | false + true | 3 | :success | :error | true + true | 3 | :error | :error | true + true | 0 | :success | :success | false + true | 0 | :error | :error | false end - it 'fails' do - is_expected.to eq(status: :error, message: 'access denied') + with_them do + before do + stub_feature_flags(container_registry_expiration_policies_throttling: feature_flag_enabled) + stub_application_setting(container_registry_cleanup_tags_service_max_list_size: max_list_size) + allow_next_instance_of(Projects::ContainerRepository::DeleteTagsService) do |service| + expect(service).to receive(:execute).and_return(status: delete_tags_service_status) + end + end + + original_size = 7 + keep_n = 1 + + it_behaves_like( + 'returning the response', + status: params[:expected_status], + original_size: original_size, + before_truncate_size: original_size - keep_n, + after_truncate_size: params[:expected_truncated] ? params[:max_list_size] + keep_n : original_size - keep_n, + before_delete_size: params[:expected_truncated] ? params[:max_list_size] : original_size - keep_n - 1 # one tag is filtered out with older_than filter + ) end end end - context 'truncating the tags list' do + context 'caching' do let(:params) do { 'name_regex_delete' => '.*', - 'keep_n' => 1 + 'keep_n' => 1, + 'older_than' => '1 day', + 'container_expiration_policy' => true + } + end + + let(:tags_and_created_ats) do + { + 'A' => 1.hour.ago, + 'Ba' => 5.days.ago, + 'Bb' => 5.days.ago, + 'C' => 1.month.ago, + 'D' => nil, + 'E' => nil } end - shared_examples 'returning the response' do |status:, original_size:, before_truncate_size:, after_truncate_size:, before_delete_size:| - it 'returns the response' do - result = subject + let(:cacheable_tags) { tags_and_created_ats.reject { |_, value| value.nil? } } - service_response = expected_service_response( - status: status, - original_size: original_size, - before_truncate_size: before_truncate_size, - after_truncate_size: after_truncate_size, - before_delete_size: before_delete_size, - deleted: nil - ) + before do + expect_delete(%w(Bb Ba C), container_expiration_policy: true) + travel_to(Time.zone.local(2021, 9, 2, 12, 0, 0)) + # We froze time so we need to set the created_at stubs again + stub_digest_config('sha256:configA', 1.hour.ago) + stub_digest_config('sha256:configB', 5.days.ago) + stub_digest_config('sha256:configC', 1.month.ago) + end - expect(result).to eq(service_response) - end + after do + travel_back end - where(:feature_flag_enabled, :max_list_size, :delete_tags_service_status, :expected_status, :expected_truncated) do - false | 10 | :success | :success | false - false | 10 | :error | :error | false - false | 3 | :success | :success | false - false | 3 | :error | :error | false - false | 0 | :success | :success | false - false | 0 | :error | :error | false - true | 10 | :success | :success | false - true | 10 | :error | :error | false - true | 3 | :success | :error | true - true | 3 | :error | :error | true - true | 0 | :success | :success | false - true | 0 | :error | :error | false + it 'caches the created_at values' do + ::Gitlab::Redis::Cache.with do |redis| + expect_mget(redis, tags_and_created_ats.keys) + + expect_set(redis, cacheable_tags) + end + + expect(subject).to include(cached_tags_count: 0) end - with_them do + context 'with cached values' do before do - stub_feature_flags(container_registry_expiration_policies_throttling: feature_flag_enabled) - stub_application_setting(container_registry_cleanup_tags_service_max_list_size: max_list_size) - allow_next_instance_of(Projects::ContainerRepository::DeleteTagsService) do |service| - expect(service).to receive(:execute).and_return(status: delete_tags_service_status) + ::Gitlab::Redis::Cache.with do |redis| + redis.set(cache_key('C'), rfc3339(1.month.ago)) + end + end + + it 'uses them' do + ::Gitlab::Redis::Cache.with do |redis| + expect_mget(redis, tags_and_created_ats.keys) + + # because C is already in cache, it should not be cached again + expect_set(redis, cacheable_tags.except('C')) + end + + # We will ping the container registry for all tags *except* for C because it's cached + expect(ContainerRegistry::Blob).to receive(:new).with(repository, "digest" => "sha256:configA").and_call_original + expect(ContainerRegistry::Blob).to receive(:new).with(repository, "digest" => "sha256:configB").twice.and_call_original + expect(ContainerRegistry::Blob).not_to receive(:new).with(repository, "digest" => "sha256:configC") + expect(ContainerRegistry::Blob).to receive(:new).with(repository, "digest" => "sha256:configD").and_call_original + + expect(subject).to include(cached_tags_count: 1) + end + end + + def expect_mget(redis, keys) + expect(redis).to receive(:mget).with(keys.map(&method(:cache_key))).and_call_original + end + + def expect_set(redis, tags) + tags.each do |tag_name, created_at| + ex = 1.day.seconds - (Time.zone.now - created_at).seconds + if ex > 0 + expect(redis).to receive(:set).with(cache_key(tag_name), rfc3339(created_at), ex: ex.to_i) end end + end + + def cache_key(tag_name) + "container_repository:{#{repository.id}}:tag:#{tag_name}:created_at" + end + + def rfc3339(date_time) + # DateTime rfc3339 is different ActiveSupport::TimeWithZone rfc3339 + # The caching will use DateTime rfc3339 + DateTime.rfc3339(date_time.rfc3339).rfc3339 + end + end + + context 'with container_registry_expiration_policies_caching enabled for the project' do + before do + stub_feature_flags(container_registry_expiration_policies_caching: project) + end + + it_behaves_like 'reading and removing tags', caching_enabled: true + end - original_size = 7 - keep_n = 1 + context 'with container_registry_expiration_policies_caching disabled' do + before do + stub_feature_flags(container_registry_expiration_policies_caching: false) + end + + it_behaves_like 'reading and removing tags', caching_enabled: false + end - it_behaves_like( - 'returning the response', - status: params[:expected_status], - original_size: original_size, - before_truncate_size: original_size - keep_n, - after_truncate_size: params[:expected_truncated] ? params[:max_list_size] + keep_n : original_size - keep_n, - before_delete_size: params[:expected_truncated] ? params[:max_list_size] : original_size - keep_n - 1 # one tag is filtered out with older_than filter - ) + context 'with container_registry_expiration_policies_caching not enabled for the project' do + let_it_be(:another_project) { create(:project) } + + before do + stub_feature_flags(container_registry_expiration_policies_caching: another_project) end + + it_behaves_like 'reading and removing tags', caching_enabled: false end end @@ -368,7 +519,19 @@ RSpec.describe Projects::ContainerRepository::CleanupTagsService do original_size: original_size, before_truncate_size: before_truncate_size, after_truncate_size: after_truncate_size, - before_delete_size: before_delete_size + before_delete_size: before_delete_size, + cached_tags_count: 0 }.compact.merge(deleted_size: deleted&.size) end + + def expect_no_caching + expect(::Gitlab::Redis::Cache).not_to receive(:with) + end + + def expect_caching + ::Gitlab::Redis::Cache.with do |redis| + expect(redis).to receive(:mget).and_call_original + expect(redis).to receive(:set).and_call_original + end + end end diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index e15d9341fd1..d7c43ac676e 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -622,6 +622,22 @@ RSpec.describe Projects::CreateService, '#execute' do end end + context 'when SAST initialization is requested' do + let(:project) { create_project(user, opts) } + + before do + opts[:initialize_with_sast] = '1' + allow(Gitlab::CurrentSettings).to receive(:default_branch_name).and_return('main') + end + + it 'creates a commit for SAST', :aggregate_failures do + expect(project.repository.commit_count).to be(1) + expect(project.repository.commit.message).to eq( + 'Configure SAST in `.gitlab-ci.yml`, creating this file if it does not already exist' + ) + end + end + describe 'create integration for the project' do subject(:project) { create_project(user, opts) } @@ -823,25 +839,23 @@ RSpec.describe Projects::CreateService, '#execute' do let_it_be(:user) { create :user } context 'when parent group is present' do - let_it_be(:group) do + let_it_be(:group, reload: true) do create(:group) do |group| group.add_owner(user) end end before do - allow_next_found_instance_of(Group) do |group| - allow(group).to receive(:shared_runners_setting).and_return(shared_runners_setting) - end + group.update_shared_runners_setting!(shared_runners_setting) user.refresh_authorized_projects # Ensure cache is warm end context 'default value based on parent group setting' do where(:shared_runners_setting, :desired_config_for_new_project, :expected_result_for_project) do - 'enabled' | nil | true - 'disabled_with_override' | nil | false - 'disabled_and_unoverridable' | nil | false + Namespace::SR_ENABLED | nil | true + Namespace::SR_DISABLED_WITH_OVERRIDE | nil | false + Namespace::SR_DISABLED_AND_UNOVERRIDABLE | nil | false end with_them do @@ -858,11 +872,11 @@ RSpec.describe Projects::CreateService, '#execute' do context 'parent group is present and allows desired config' do where(:shared_runners_setting, :desired_config_for_new_project, :expected_result_for_project) do - 'enabled' | true | true - 'enabled' | false | false - 'disabled_with_override' | false | false - 'disabled_with_override' | true | true - 'disabled_and_unoverridable' | false | false + Namespace::SR_ENABLED | true | true + Namespace::SR_ENABLED | false | false + Namespace::SR_DISABLED_WITH_OVERRIDE | false | false + Namespace::SR_DISABLED_WITH_OVERRIDE | true | true + Namespace::SR_DISABLED_AND_UNOVERRIDABLE | false | false end with_them do @@ -878,7 +892,7 @@ RSpec.describe Projects::CreateService, '#execute' do context 'parent group is present and disallows desired config' do where(:shared_runners_setting, :desired_config_for_new_project) do - 'disabled_and_unoverridable' | true + Namespace::SR_DISABLED_AND_UNOVERRIDABLE | true end with_them do diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb index 27688d8c966..9bdd9800fcc 100644 --- a/spec/services/projects/destroy_service_spec.rb +++ b/spec/services/projects/destroy_service_spec.rb @@ -39,26 +39,64 @@ RSpec.describe Projects::DestroyService, :aggregate_failures do 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) } + let!(:pipeline_artifact) { create(:ci_pipeline_artifact, pipeline: pipeline) } - it 'deletes build related records' do - expect { destroy_project(project, user, {}) }.to change { Ci::Build.count }.by(-1) + it 'deletes build and pipeline 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::DeletedObject.count }.by(2) + .and change { Ci::PipelineArtifact.count }.by(-1) .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) + .and change { Ci::Pipeline.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, {}) } + context 'with abort_deleted_project_pipelines disabled' do + stub_feature_flags(abort_deleted_project_pipelines: false) - 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]) + it 'avoids N+1 queries' do + recorder = ActiveRecord::QueryRecorder.new { destroy_project(project, user, {}) } - expect { destroy_project(project, project.owner, {}) }.not_to exceed_query_limit(recorder) + project = create(:project, :repository, namespace: user.namespace) + pipeline = create(:ci_pipeline, project: project) + builds = create_list(:ci_build, 3, :artifacts, pipeline: pipeline) + create(:ci_pipeline_artifact, 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 + end + + context 'with ci_optimize_project_records_destruction disabled' do + stub_feature_flags(ci_optimize_project_records_destruction: false) + + it 'avoids N+1 queries' 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 + end + + context 'with ci_optimize_project_records_destruction and abort_deleted_project_pipelines enabled' do + it 'avoids N+1 queries' 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 end it_behaves_like 'deleting the project' @@ -95,24 +133,63 @@ RSpec.describe Projects::DestroyService, :aggregate_failures do end context 'with abort_deleted_project_pipelines feature disabled' do - it 'does not cancel project ci pipelines' do + before do stub_feature_flags(abort_deleted_project_pipelines: false) + end + it 'does not bulk-fail project ci pipelines' do expect(::Ci::AbortPipelinesService).not_to receive(:new) destroy_project(project, user, {}) end + + it 'does not destroy CI records via DestroyPipelineService' do + expect(::Ci::DestroyPipelineService).not_to receive(:new) + + destroy_project(project, user, {}) + end end context 'with abort_deleted_project_pipelines feature enabled' do - it 'performs cancel for project ci pipelines' do - stub_feature_flags(abort_deleted_project_pipelines: true) - pipelines = build_list(:ci_pipeline, 3, :running) - allow(project).to receive(:all_pipelines).and_return(pipelines) + let!(:pipelines) { create_list(:ci_pipeline, 3, :running, project: project) } + let(:destroy_pipeline_service) { double('DestroyPipelineService', execute: nil) } - expect(::Ci::AbortPipelinesService).to receive_message_chain(:new, :execute).with(pipelines, :project_deleted) + context 'with ci_optimize_project_records_destruction disabled' do + before do + stub_feature_flags(ci_optimize_project_records_destruction: false) + end - destroy_project(project, user, {}) + it 'bulk-fails project ci pipelines' do + expect(::Ci::AbortPipelinesService) + .to receive_message_chain(:new, :execute) + .with(project.all_pipelines, :project_deleted) + + destroy_project(project, user, {}) + end + + it 'does not destroy CI records via DestroyPipelineService' do + expect(::Ci::DestroyPipelineService).not_to receive(:new) + + destroy_project(project, user, {}) + end + end + + context 'with ci_optimize_project_records_destruction enabled' do + it 'executes DestroyPipelineService for project ci pipelines' do + allow(::Ci::DestroyPipelineService).to receive(:new).and_return(destroy_pipeline_service) + + expect(::Ci::AbortPipelinesService) + .to receive_message_chain(:new, :execute) + .with(project.all_pipelines, :project_deleted) + + pipelines.each do |pipeline| + expect(destroy_pipeline_service) + .to receive(:execute) + .with(pipeline) + end + + destroy_project(project, user, {}) + end end end diff --git a/spec/services/projects/group_links/update_service_spec.rb b/spec/services/projects/group_links/update_service_spec.rb index 4a38fb0c7d9..ff1618c3bbe 100644 --- a/spec/services/projects/group_links/update_service_spec.rb +++ b/spec/services/projects/group_links/update_service_spec.rb @@ -34,86 +34,40 @@ RSpec.describe Projects::GroupLinks::UpdateService, '#execute' do end context 'project authorizations update' do - context 'when the feature flag `specialized_worker_for_project_share_update_auth_recalculation` is enabled' do - before do - stub_feature_flags(specialized_worker_for_project_share_update_auth_recalculation: true) - end - - it 'calls AuthorizedProjectUpdate::ProjectRecalculateWorker to update project authorizations' do - expect(AuthorizedProjectUpdate::ProjectRecalculateWorker) - .to receive(:perform_async).with(link.project.id) - - subject - end - - it 'calls AuthorizedProjectUpdate::UserRefreshFromReplicaWorker with a delay to update project authorizations' do - expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to( - receive(:bulk_perform_in) - .with(1.hour, - [[user.id]], - batch_delay: 30.seconds, batch_size: 100) - ) - - subject - end - - it 'updates project authorizations of users who had access to the project via the group share', :sidekiq_inline do - group.add_maintainer(user) - - expect { subject }.to( - change { Ability.allowed?(user, :create_release, project) } - .from(true).to(false)) - end - end + it 'calls AuthorizedProjectUpdate::ProjectRecalculateWorker to update project authorizations' do + expect(AuthorizedProjectUpdate::ProjectRecalculateWorker) + .to receive(:perform_async).with(link.project.id) - context 'when the feature flag `specialized_worker_for_project_share_update_auth_recalculation` is disabled' do - before do - stub_feature_flags(specialized_worker_for_project_share_update_auth_recalculation: false) - end + subject + end - it 'calls UserProjectAccessChangedService to update project authorizations' do - expect_next_instance_of(UserProjectAccessChangedService, [user.id]) do |service| - expect(service).to receive(:execute) - end + it 'calls AuthorizedProjectUpdate::UserRefreshFromReplicaWorker with a delay to update project authorizations' do + expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to( + receive(:bulk_perform_in) + .with(1.hour, + [[user.id]], + batch_delay: 30.seconds, batch_size: 100) + ) - subject - end + subject + end - it 'updates project authorizations of users who had access to the project via the group share' do - group.add_maintainer(user) + it 'updates project authorizations of users who had access to the project via the group share', :sidekiq_inline do + group.add_maintainer(user) - expect { subject }.to( - change { Ability.allowed?(user, :create_release, project) } - .from(true).to(false)) - end + expect { subject }.to( + change { Ability.allowed?(user, :create_release, project) } + .from(true).to(false)) end end context 'with only param not requiring authorization refresh' do let(:group_link_params) { { expires_at: Date.tomorrow } } - context 'when the feature flag `specialized_worker_for_project_share_update_auth_recalculation` is enabled' do - before do - stub_feature_flags(specialized_worker_for_project_share_update_auth_recalculation: true) - end - - it 'does not perform any project authorizations update using `AuthorizedProjectUpdate::ProjectRecalculateWorker`' do - expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).not_to receive(:perform_async) - - subject - end - end - - context 'when the feature flag `specialized_worker_for_project_share_update_auth_recalculation` is disabled' do - before do - stub_feature_flags(specialized_worker_for_project_share_update_auth_recalculation: false) - end - - it 'does not perform any project authorizations update using `UserProjectAccessChangedService`' do - expect(UserProjectAccessChangedService).not_to receive(:new) + it 'does not perform any project authorizations update using `AuthorizedProjectUpdate::ProjectRecalculateWorker`' do + expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).not_to receive(:perform_async) - subject - end + subject end end end diff --git a/spec/services/projects/import_service_spec.rb b/spec/services/projects/import_service_spec.rb index 92e18b6cb46..1d63f72ec38 100644 --- a/spec/services/projects/import_service_spec.rb +++ b/spec/services/projects/import_service_spec.rb @@ -86,6 +86,12 @@ RSpec.describe Projects::ImportService do end context 'with a Github repository' do + it 'tracks the start of import' do + expect(Gitlab::GithubImport::ParallelImporter).to receive(:track_start_import) + + subject.execute + end + it 'succeeds if repository import was scheduled' do expect_any_instance_of(Gitlab::GithubImport::ParallelImporter) .to receive(:execute) diff --git a/spec/services/projects/move_access_service_spec.rb b/spec/services/projects/move_access_service_spec.rb index 90167ffebed..45e10c3ca84 100644 --- a/spec/services/projects/move_access_service_spec.rb +++ b/spec/services/projects/move_access_service_spec.rb @@ -26,7 +26,7 @@ RSpec.describe Projects::MoveAccessService do describe '#execute' do shared_examples 'move the accesses' do - it do + it 'moves the accesses', :sidekiq_inline do expect(project_with_access.project_members.count).to eq 4 expect(project_with_access.project_group_links.count).to eq 3 expect(project_with_access.authorized_users.count).to eq 4 diff --git a/spec/services/projects/operations/update_service_spec.rb b/spec/services/projects/operations/update_service_spec.rb index a71fafb2121..b64f2d1e7d6 100644 --- a/spec/services/projects/operations/update_service_spec.rb +++ b/spec/services/projects/operations/update_service_spec.rb @@ -294,10 +294,10 @@ RSpec.describe Projects::Operations::UpdateService do end context 'without setting' do - it 'does not create a setting' do - expect(result[:status]).to eq(:error) - - expect(project.reload.error_tracking_setting).to be_nil + it 'creates setting with default values' do + expect(result[:status]).to eq(:success) + expect(project.error_tracking_setting.enabled).to be_truthy + expect(project.error_tracking_setting.integrated).to be_truthy end end end diff --git a/spec/services/projects/participants_service_spec.rb b/spec/services/projects/participants_service_spec.rb index b84e28314f2..eab7228307a 100644 --- a/spec/services/projects/participants_service_spec.rb +++ b/spec/services/projects/participants_service_spec.rb @@ -104,104 +104,116 @@ RSpec.describe Projects::ParticipantsService do describe '#project_members' do subject(:usernames) { service.project_members.map { |member| member[:username] } } - context 'when there is a project in group namespace' do - let_it_be(:public_group) { create(:group, :public) } - let_it_be(:public_project) { create(:project, :public, namespace: public_group)} + shared_examples 'return project members' do + context 'when there is a project in group namespace' do + let_it_be(:public_group) { create(:group, :public) } + let_it_be(:public_project) { create(:project, :public, namespace: public_group)} - let_it_be(:public_group_owner) { create(:user) } + let_it_be(:public_group_owner) { create(:user) } - let(:service) { described_class.new(public_project, create(:user)) } + let(:service) { described_class.new(public_project, create(:user)) } - before do - public_group.add_owner(public_group_owner) - end + before do + public_group.add_owner(public_group_owner) + end - it 'returns members of a group' do - expect(usernames).to include(public_group_owner.username) + it 'returns members of a group' do + expect(usernames).to include(public_group_owner.username) + end end - end - - context 'when there is a private group and a public project' do - let_it_be(:public_group) { create(:group, :public) } - let_it_be(:private_group) { create(:group, :private, :nested) } - let_it_be(:public_project) { create(:project, :public, namespace: public_group)} - let_it_be(:project_issue) { create(:issue, project: public_project)} + context 'when there is a private group and a public project' do + let_it_be(:public_group) { create(:group, :public) } + let_it_be(:private_group) { create(:group, :private, :nested) } + let_it_be(:public_project) { create(:project, :public, namespace: public_group)} - let_it_be(:public_group_owner) { create(:user) } - let_it_be(:private_group_member) { create(:user) } - let_it_be(:public_project_maintainer) { create(:user) } - let_it_be(:private_group_owner) { create(:user) } + let_it_be(:project_issue) { create(:issue, project: public_project)} - let_it_be(:group_ancestor_owner) { create(:user) } + let_it_be(:public_group_owner) { create(:user) } + let_it_be(:private_group_member) { create(:user) } + let_it_be(:public_project_maintainer) { create(:user) } + let_it_be(:private_group_owner) { create(:user) } - before_all do - public_group.add_owner public_group_owner - private_group.add_developer private_group_member - public_project.add_maintainer public_project_maintainer + let_it_be(:group_ancestor_owner) { create(:user) } - private_group.add_owner private_group_owner - private_group.parent.add_owner group_ancestor_owner - end - - context 'when the private group is invited to the public project' do before_all do - create(:project_group_link, group: private_group, project: public_project) - end + public_group.add_owner public_group_owner + private_group.add_developer private_group_member + public_project.add_maintainer public_project_maintainer - context 'when a user who is outside the public project and the private group is signed in' do - let(:service) { described_class.new(public_project, create(:user)) } + private_group.add_owner private_group_owner + private_group.parent.add_owner group_ancestor_owner + end - it 'does not return the private group' do - expect(usernames).not_to include(private_group.name) + context 'when the private group is invited to the public project' do + before_all do + create(:project_group_link, group: private_group, project: public_project) end - it 'does not return private group members' do - expect(usernames).not_to include(private_group_member.username) - end + context 'when a user who is outside the public project and the private group is signed in' do + let(:service) { described_class.new(public_project, create(:user)) } - it 'returns the project maintainer' do - expect(usernames).to include(public_project_maintainer.username) - end + it 'does not return the private group' do + expect(usernames).not_to include(private_group.name) + end - it 'returns project members from an invited public group' do - invited_public_group = create(:group, :public) - invited_public_group.add_owner create(:user) + it 'does not return private group members' do + expect(usernames).not_to include(private_group_member.username) + end - create(:project_group_link, group: invited_public_group, project: public_project) + it 'returns the project maintainer' do + expect(usernames).to include(public_project_maintainer.username) + end - expect(usernames).to include(invited_public_group.users.first.username) - end + it 'returns project members from an invited public group' do + invited_public_group = create(:group, :public) + invited_public_group.add_owner create(:user) - it 'does not return ancestors of the private group' do - expect(usernames).not_to include(group_ancestor_owner.username) - end - end + create(:project_group_link, group: invited_public_group, project: public_project) - context 'when private group owner is signed in' do - let(:service) { described_class.new(public_project, private_group_owner) } + expect(usernames).to include(invited_public_group.users.first.username) + end - it 'returns private group members' do - expect(usernames).to include(private_group_member.username) + it 'does not return ancestors of the private group' do + expect(usernames).not_to include(group_ancestor_owner.username) + end end - it 'returns ancestors of the the private group' do - expect(usernames).to include(group_ancestor_owner.username) - end - end + context 'when private group owner is signed in' do + let(:service) { described_class.new(public_project, private_group_owner) } - context 'when the namespace owner of the public project is signed in' do - let(:service) { described_class.new(public_project, public_group_owner) } + it 'returns private group members' do + expect(usernames).to include(private_group_member.username) + end - it 'returns private group members' do - expect(usernames).to include(private_group_member.username) + it 'returns ancestors of the the private group' do + expect(usernames).to include(group_ancestor_owner.username) + end end - it 'does not return members of the ancestral groups of the private group' do - expect(usernames).to include(group_ancestor_owner.username) + context 'when the namespace owner of the public project is signed in' do + let(:service) { described_class.new(public_project, public_group_owner) } + + it 'returns private group members' do + expect(usernames).to include(private_group_member.username) + end + + it 'does not return members of the ancestral groups of the private group' do + expect(usernames).to include(group_ancestor_owner.username) + end end end end end + + it_behaves_like 'return project members' + + context 'when feature flag :linear_participants_service_ancestor_scopes is disabled' do + before do + stub_feature_flags(linear_participants_service_ancestor_scopes: false) + end + + it_behaves_like 'return project members' + end end end diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb index d96573e26af..b539b01066e 100644 --- a/spec/services/projects/transfer_service_spec.rb +++ b/spec/services/projects/transfer_service_spec.rb @@ -64,6 +64,33 @@ RSpec.describe Projects::TransferService do expect(transfer_result).to be_truthy expect(project.namespace).to eq(group) end + + context 'when project has an associated project namespace' do + let!(:project_namespace) { create(:project_namespace, project: project) } + + it 'keeps project namespace in sync with project' do + transfer_result = execute_transfer + + expect(transfer_result).to be_truthy + + project_namespace_in_sync(group) + end + + context 'when project is transferred to a deeper nested group' do + let(:parent_group) { create(:group) } + let(:sub_group) { create(:group, parent: parent_group) } + let(:sub_sub_group) { create(:group, parent: sub_group) } + let(:group) { sub_sub_group } + + it 'keeps project namespace in sync with project' do + transfer_result = execute_transfer + + expect(transfer_result).to be_truthy + + project_namespace_in_sync(sub_sub_group) + end + end + end end context 'when transfer succeeds' do @@ -143,6 +170,28 @@ RSpec.describe Projects::TransferService do end end end + + context 'when project has pending builds' do + let!(:other_project) { create(:project) } + let!(:pending_build) { create(:ci_pending_build, project: project.reload) } + let!(:unrelated_pending_build) { create(:ci_pending_build, project: other_project) } + + before do + group.reload + end + + it 'updates pending builds for the project', :aggregate_failures do + execute_transfer + + pending_build.reload + unrelated_pending_build.reload + + expect(pending_build.namespace_id).to eq(group.id) + expect(pending_build.namespace_traversal_ids).to eq(group.traversal_ids) + expect(unrelated_pending_build.namespace_id).to eq(other_project.namespace_id) + expect(unrelated_pending_build.namespace_traversal_ids).to eq(other_project.namespace.traversal_ids) + end + end end context 'when transfer fails' do @@ -203,6 +252,34 @@ RSpec.describe Projects::TransferService do shard_name: project.repository_storage ) end + + context 'when project has pending builds' do + let!(:other_project) { create(:project) } + let!(:pending_build) { create(:ci_pending_build, project: project.reload) } + let!(:unrelated_pending_build) { create(:ci_pending_build, project: other_project) } + + it 'does not update pending builds for the project', :aggregate_failures do + attempt_project_transfer + + pending_build.reload + unrelated_pending_build.reload + + expect(pending_build.namespace_id).to eq(project.namespace_id) + expect(pending_build.namespace_traversal_ids).to eq(project.namespace.traversal_ids) + expect(unrelated_pending_build.namespace_id).to eq(other_project.namespace_id) + expect(unrelated_pending_build.namespace_traversal_ids).to eq(other_project.namespace.traversal_ids) + end + end + + context 'when project has an associated project namespace' do + let!(:project_namespace) { create(:project_namespace, project: project) } + + it 'keeps project namespace in sync with project' do + attempt_project_transfer + + project_namespace_in_sync(user.namespace) + end + end end context 'namespace -> no namespace' do @@ -215,6 +292,18 @@ RSpec.describe Projects::TransferService do expect(project.namespace).to eq(user.namespace) expect(project.errors.messages[:new_namespace].first).to eq 'Please select a new namespace for your project.' end + + context 'when project has an associated project namespace' do + let!(:project_namespace) { create(:project_namespace, project: project) } + + it 'keeps project namespace in sync with project' do + transfer_result = execute_transfer + + expect(transfer_result).to be false + + project_namespace_in_sync(user.namespace) + end + end end context 'disallow transferring of project with tags' do @@ -369,28 +458,23 @@ RSpec.describe Projects::TransferService do using RSpec::Parameterized::TableSyntax where(:project_shared_runners_enabled, :shared_runners_setting, :expected_shared_runners_enabled) do - true | 'disabled_and_unoverridable' | false - false | 'disabled_and_unoverridable' | false - true | 'disabled_with_override' | true - false | 'disabled_with_override' | false - true | 'enabled' | true - false | 'enabled' | false + true | :disabled_and_unoverridable | false + false | :disabled_and_unoverridable | false + true | :disabled_with_override | true + false | :disabled_with_override | false + true | :shared_runners_enabled | true + false | :shared_runners_enabled | false end with_them do let(:project) { create(:project, :public, :repository, namespace: user.namespace, shared_runners_enabled: project_shared_runners_enabled) } - let(:group) { create(:group) } + let(:group) { create(:group, shared_runners_setting) } - before do + it 'updates shared runners based on the parent group' do group.add_owner(user) - expect_next_found_instance_of(Group) do |group| - expect(group).to receive(:shared_runners_setting).and_return(shared_runners_setting) - end - execute_transfer - end + expect(execute_transfer).to eq(true) - it 'updates shared runners based on the parent group' do expect(project.shared_runners_enabled).to eq(expected_shared_runners_enabled) end end @@ -478,58 +562,30 @@ RSpec.describe Projects::TransferService do group.add_owner(user) end - context 'when the feature flag `specialized_worker_for_project_transfer_auth_recalculation` is enabled' do - before do - stub_feature_flags(specialized_worker_for_project_transfer_auth_recalculation: true) - end - - it 'calls AuthorizedProjectUpdate::ProjectRecalculateWorker to update project authorizations' do - expect(AuthorizedProjectUpdate::ProjectRecalculateWorker) - .to receive(:perform_async).with(project.id) - - execute_transfer - end + it 'calls AuthorizedProjectUpdate::ProjectRecalculateWorker to update project authorizations' do + expect(AuthorizedProjectUpdate::ProjectRecalculateWorker) + .to receive(:perform_async).with(project.id) - it 'calls AuthorizedProjectUpdate::UserRefreshFromReplicaWorker with a delay to update project authorizations' do - user_ids = [user.id, member_of_old_group.id, member_of_new_group.id].map { |id| [id] } - - expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to( - receive(:bulk_perform_in) - .with(1.hour, - user_ids, - batch_delay: 30.seconds, batch_size: 100) - ) - - subject - end - - it 'refreshes the permissions of the members of the old and new namespace', :sidekiq_inline do - expect { execute_transfer } - .to change { member_of_old_group.authorized_projects.include?(project) }.from(true).to(false) - .and change { member_of_new_group.authorized_projects.include?(project) }.from(false).to(true) - end + execute_transfer end - context 'when the feature flag `specialized_worker_for_project_transfer_auth_recalculation` is disabled' do - before do - stub_feature_flags(specialized_worker_for_project_transfer_auth_recalculation: false) - end - - it 'calls UserProjectAccessChangedService to update project authorizations' do - user_ids = [user.id, member_of_old_group.id, member_of_new_group.id] + it 'calls AuthorizedProjectUpdate::UserRefreshFromReplicaWorker with a delay to update project authorizations' do + user_ids = [user.id, member_of_old_group.id, member_of_new_group.id].map { |id| [id] } - expect_next_instance_of(UserProjectAccessChangedService, user_ids) do |service| - expect(service).to receive(:execute) - end + expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to( + receive(:bulk_perform_in) + .with(1.hour, + user_ids, + batch_delay: 30.seconds, batch_size: 100) + ) - execute_transfer - end + subject + end - it 'refreshes the permissions of the members of the old and new namespace' do - expect { execute_transfer } - .to change { member_of_old_group.authorized_projects.include?(project) }.from(true).to(false) - .and change { member_of_new_group.authorized_projects.include?(project) }.from(false).to(true) - end + it 'refreshes the permissions of the members of the old and new namespace', :sidekiq_inline do + expect { execute_transfer } + .to change { member_of_old_group.authorized_projects.include?(project) }.from(true).to(false) + .and change { member_of_new_group.authorized_projects.include?(project) }.from(false).to(true) end end @@ -643,4 +699,13 @@ RSpec.describe Projects::TransferService do def rugged_config rugged_repo(project.repository).config end + + def project_namespace_in_sync(group) + project.reload + expect(project.namespace).to eq(group) + expect(project.project_namespace.visibility_level).to eq(project.visibility_level) + expect(project.project_namespace.path).to eq(project.path) + expect(project.project_namespace.parent).to eq(project.namespace) + expect(project.project_namespace.traversal_ids).to eq([*project.namespace.traversal_ids, project.project_namespace.id]) + end end diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb index 6d0b75e0c95..5810024a1ef 100644 --- a/spec/services/projects/update_pages_service_spec.rb +++ b/spec/services/projects/update_pages_service_spec.rb @@ -173,14 +173,6 @@ RSpec.describe Projects::UpdatePagesService do include_examples 'successfully deploys' - context 'when pages_smart_check_outdated_sha feature flag is disabled' do - before do - stub_feature_flags(pages_smart_check_outdated_sha: false) - end - - include_examples 'fails with outdated reference message' - end - context 'when old deployment present' do before do old_build = create(:ci_build, pipeline: old_pipeline, ref: 'HEAD') @@ -189,14 +181,6 @@ RSpec.describe Projects::UpdatePagesService do end include_examples 'successfully deploys' - - context 'when pages_smart_check_outdated_sha feature flag is disabled' do - before do - stub_feature_flags(pages_smart_check_outdated_sha: false) - end - - include_examples 'fails with outdated reference message' - end end context 'when newer deployment present' do diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index 115f3098185..4923ef169e8 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -374,7 +374,7 @@ RSpec.describe Projects::UpdateService do expect(result).to eq({ status: :error, - message: "Name can contain only letters, digits, emojis, '_', '.', dash, space. It must start with letter, digit, emoji or '_'." + message: "Name can contain only letters, digits, emojis, '_', '.', '+', dashes, or spaces. It must start with a letter, digit, emoji, or '_'." }) end end @@ -441,26 +441,62 @@ RSpec.describe Projects::UpdateService do end end - context 'when updating #shared_runners', :https_pages_enabled do - let!(:pending_build) { create(:ci_pending_build, project: project, instance_runners_enabled: true) } + context 'when updating runners settings' do + let(:settings) do + { instance_runners_enabled: true, namespace_traversal_ids: [123] } + end - subject(:call_service) do - update_project(project, admin, shared_runners_enabled: shared_runners_enabled) + let!(:pending_build) do + create(:ci_pending_build, project: project, **settings) + end + + context 'when project has shared runners enabled' do + let(:project) { create(:project, shared_runners_enabled: true) } + + it 'updates builds queue when shared runners get disabled' do + expect { update_project(project, admin, shared_runners_enabled: false) } + .to change { pending_build.reload.instance_runners_enabled }.to(false) + + expect(pending_build.reload.instance_runners_enabled).to be false + end + end + + context 'when project has shared runners disabled' do + let(:project) { create(:project, shared_runners_enabled: false) } + + it 'updates builds queue when shared runners get enabled' do + expect { update_project(project, admin, shared_runners_enabled: true) } + .to not_change { pending_build.reload.instance_runners_enabled } + + expect(pending_build.reload.instance_runners_enabled).to be true + end end - context 'when shared runners is toggled' do - let(:shared_runners_enabled) { false } + context 'when project has group runners enabled' do + let(:project) { create(:project, group_runners_enabled: true) } + + before do + project.ci_cd_settings.update!(group_runners_enabled: true) + end + + it 'updates builds queue when group runners get disabled' do + update_project(project, admin, group_runners_enabled: false) - it 'updates ci pending builds' do - expect { call_service }.to change { pending_build.reload.instance_runners_enabled }.to(false) + expect(pending_build.reload.namespace_traversal_ids).to be_empty end end - context 'when shared runners is not toggled' do - let(:shared_runners_enabled) { true } + context 'when project has group runners disabled' do + let(:project) { create(:project, :in_subgroup, group_runners_enabled: false) } + + before do + project.reload.ci_cd_settings.update!(group_runners_enabled: false) + end + + it 'updates builds queue when group runners get enabled' do + update_project(project, admin, group_runners_enabled: true) - it 'updates ci pending builds' do - expect { call_service }.to not_change { pending_build.reload.instance_runners_enabled } + expect(pending_build.reload.namespace_traversal_ids).to include(project.namespace.id) end end end diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb index 02997096021..d67b189f90e 100644 --- a/spec/services/quick_actions/interpret_service_spec.rb +++ b/spec/services/quick_actions/interpret_service_spec.rb @@ -1935,6 +1935,21 @@ RSpec.describe QuickActions::InterpretService do it_behaves_like 'relate command' end + context 'when quick action target is unpersisted' do + let(:issue) { build(:issue, project: project) } + let(:other_issue) { create(:issue, project: project) } + let(:issues_related) { [other_issue] } + let(:content) { "/relate #{other_issue.to_reference}" } + + it 'relates the issues after the issue is persisted' do + service.execute(content, issue) + + issue.save! + + expect(IssueLink.where(source: issue).map(&:target)).to match_array(issues_related) + end + end + context 'empty relate command' do let(:issues_related) { [] } let(:content) { '/relate' } diff --git a/spec/services/security/ci_configuration/sast_create_service_spec.rb b/spec/services/security/ci_configuration/sast_create_service_spec.rb index 44f8f07a5be..c7e732dc79a 100644 --- a/spec/services/security/ci_configuration/sast_create_service_spec.rb +++ b/spec/services/security/ci_configuration/sast_create_service_spec.rb @@ -23,4 +23,27 @@ RSpec.describe Security::CiConfiguration::SastCreateService, :snowplow do end include_examples 'services security ci configuration create service' + + context "when committing to the default branch", :aggregate_failures do + subject(:result) { described_class.new(project, user, params, commit_on_default: true).execute } + + let(:params) { {} } + + before do + project.add_developer(user) + end + + it "doesn't try to remove that branch on raised exceptions" do + expect(Files::MultiService).to receive(:new).and_raise(StandardError, '_exception_') + expect(project.repository).not_to receive(:rm_branch) + + expect { result }.to raise_error(StandardError, '_exception_') + end + + it "commits directly to the default branch" do + expect(result.status).to eq(:success) + expect(result.payload[:success_path]).to match(/#{Gitlab::Routing.url_helpers.project_new_merge_request_url(project, {})}(.*)description(.*)source_branch/) + expect(result.payload[:branch]).to eq('master') + end + end end diff --git a/spec/services/service_ping/submit_service_ping_service_spec.rb b/spec/services/service_ping/submit_service_ping_service_spec.rb index c2fe565938a..d8672eec682 100644 --- a/spec/services/service_ping/submit_service_ping_service_spec.rb +++ b/spec/services/service_ping/submit_service_ping_service_spec.rb @@ -50,6 +50,7 @@ RSpec.describe ServicePing::SubmitService do let(:with_dev_ops_score_params) { { dev_ops_score: score_params[:score] } } let(:with_conv_index_params) { { conv_index: score_params[:score] } } + let(:with_usage_data_id_params) { { conv_index: { usage_data_id: usage_data_id } } } shared_examples 'does not run' do it do @@ -173,6 +174,29 @@ RSpec.describe ServicePing::SubmitService do end end + context 'when only usage_data_id is passed in response' do + before do + stub_response(body: with_usage_data_id_params) + end + + it 'does not save DevOps report data' do + expect { subject.execute }.not_to change { DevOpsReport::Metric.count } + end + + it 'saves usage_data_id to version_usage_data_id_value' do + recorded_at = Time.current + usage_data = { uuid: 'uuid', recorded_at: recorded_at } + + expect(Gitlab::UsageData).to receive(:data).with(force_refresh: true).and_return(usage_data) + + subject.execute + + raw_usage_data = RawUsageData.find_by(recorded_at: recorded_at) + + expect(raw_usage_data.version_usage_data_id_value).to eq(31643) + end + end + context 'when version app usage_data_id is invalid' do let(:usage_data_id) { -1000 } diff --git a/spec/services/user_project_access_changed_service_spec.rb b/spec/services/user_project_access_changed_service_spec.rb index f8835fefc84..438db6b987b 100644 --- a/spec/services/user_project_access_changed_service_spec.rb +++ b/spec/services/user_project_access_changed_service_spec.rb @@ -47,15 +47,13 @@ RSpec.describe UserProjectAccessChangedService do let(:service) { UserProjectAccessChangedService.new([1, 2]) } before do - allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(true) - expect(AuthorizedProjectsWorker).to receive(:bulk_perform_and_wait) .with([[1], [2]]) .and_return(10) end it 'sticks all the updated users and returns the original result', :aggregate_failures do - expect(Gitlab::Database::LoadBalancing::Sticking).to receive(:bulk_stick).with(:user, [1, 2]) + expect(ApplicationRecord.sticking).to receive(:bulk_stick).with(:user, [1, 2]) expect(service.execute).to eq(10) end diff --git a/spec/services/users/activity_service_spec.rb b/spec/services/users/activity_service_spec.rb index 6c1df5c745f..092c5cd3e5e 100644 --- a/spec/services/users/activity_service_spec.rb +++ b/spec/services/users/activity_service_spec.rb @@ -91,9 +91,9 @@ RSpec.describe Users::ActivityService do context 'when last activity is in the past' do let(:user) { create(:user, last_activity_on: Date.today - 1.week) } - context 'database load balancing is configured', :db_load_balancing do + context 'database load balancing is configured' do before do - allow(ActiveRecord::Base).to receive(:connection).and_return(::Gitlab::Database::LoadBalancing.proxy) + ::Gitlab::Database::LoadBalancing::Session.clear_session end let(:service) do diff --git a/spec/services/users/update_service_spec.rb b/spec/services/users/update_service_spec.rb index b30b7e6eb56..3244db4c1fb 100644 --- a/spec/services/users/update_service_spec.rb +++ b/spec/services/users/update_service_spec.rb @@ -3,7 +3,8 @@ require 'spec_helper' RSpec.describe Users::UpdateService do - let(:user) { create(:user) } + let(:password) { 'longsecret987!' } + let(:user) { create(:user, password: password, password_confirmation: password) } describe '#execute' do it 'updates time preferences' do @@ -18,7 +19,7 @@ RSpec.describe Users::UpdateService do it 'returns an error result when record cannot be updated' do result = {} expect do - result = update_user(user, { email: 'invalid' }) + result = update_user(user, { email: 'invalid', validation_password: password }) end.not_to change { user.reload.email } expect(result[:status]).to eq(:error) expect(result[:message]).to eq('Email is invalid') @@ -65,7 +66,7 @@ RSpec.describe Users::UpdateService do context 'updating canonical email' do context 'if email was changed' do subject do - update_user(user, email: 'user+extrastuff@example.com') + update_user(user, email: 'user+extrastuff@example.com', validation_password: password) end it 'calls canonicalize_email' do @@ -75,15 +76,68 @@ RSpec.describe Users::UpdateService do subject end + + context 'when check_password is true' do + def update_user(user, opts) + described_class.new(user, opts.merge(user: user)).execute(check_password: true) + end + + it 'returns error if no password confirmation was passed', :aggregate_failures do + result = {} + + expect do + result = update_user(user, { email: 'example@example.com' }) + end.not_to change { user.reload.unconfirmed_email } + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq('Invalid password') + end + + it 'returns error if wrong password confirmation was passed', :aggregate_failures do + result = {} + + expect do + result = update_user(user, { email: 'example@example.com', validation_password: 'wrongpassword' }) + end.not_to change { user.reload.unconfirmed_email } + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq('Invalid password') + end + + it 'does not require password if it was automatically set', :aggregate_failures do + user.update!(password_automatically_set: true) + result = {} + + expect do + result = update_user(user, { email: 'example@example.com' }) + end.to change { user.reload.unconfirmed_email } + expect(result[:status]).to eq(:success) + end + + it 'does not require a password if the attribute changed does not require it' do + result = {} + + expect do + result = update_user(user, { job_title: 'supreme leader of the universe' }) + end.to change { user.reload.job_title } + expect(result[:status]).to eq(:success) + end + end end - context 'if email was NOT changed' do - subject do - update_user(user, job_title: 'supreme leader of the universe') + context 'when check_password is left to false' do + it 'does not require a password check', :aggregate_failures do + result = {} + expect do + result = update_user(user, { email: 'example@example.com' }) + end.to change { user.reload.unconfirmed_email } + expect(result[:status]).to eq(:success) end + end + context 'if email was NOT changed' do it 'skips update canonicalize email service call' do - expect { subject }.not_to change { user.user_canonical_email } + expect do + update_user(user, job_title: 'supreme leader of the universe') + end.not_to change { user.user_canonical_email } end end end @@ -106,7 +160,7 @@ RSpec.describe Users::UpdateService do it 'raises an error when record cannot be updated' do expect do - update_user(user, email: 'invalid') + update_user(user, email: 'invalid', validation_password: password) end.to raise_error(ActiveRecord::RecordInvalid) end diff --git a/spec/services/users/upsert_credit_card_validation_service_spec.rb b/spec/services/users/upsert_credit_card_validation_service_spec.rb index 148638fe5e7..bede30e1898 100644 --- a/spec/services/users/upsert_credit_card_validation_service_spec.rb +++ b/spec/services/users/upsert_credit_card_validation_service_spec.rb @@ -7,7 +7,17 @@ RSpec.describe Users::UpsertCreditCardValidationService do let(:user_id) { user.id } let(:credit_card_validated_time) { Time.utc(2020, 1, 1) } - let(:params) { { user_id: user_id, credit_card_validated_at: credit_card_validated_time } } + let(:expiration_year) { Date.today.year + 10 } + let(:params) do + { + user_id: user_id, + credit_card_validated_at: credit_card_validated_time, + credit_card_expiration_year: expiration_year, + credit_card_expiration_month: 1, + credit_card_holder_name: 'John Smith', + credit_card_mask_number: '1111' + } + end describe '#execute' do subject(:service) { described_class.new(params) } @@ -52,6 +62,16 @@ RSpec.describe Users::UpsertCreditCardValidationService do end end + shared_examples 'returns an error, tracking the exception' do + it do + expect(Gitlab::ErrorTracking).to receive(:track_exception) + + result = service.execute + + expect(result.status).to eq(:error) + end + end + context 'when user id does not exist' do let(:user_id) { non_existing_record_id } @@ -61,19 +81,27 @@ RSpec.describe Users::UpsertCreditCardValidationService do context 'when missing credit_card_validated_at' do let(:params) { { user_id: user_id } } - it_behaves_like 'returns an error without tracking the exception' + it_behaves_like 'returns an error, tracking the exception' end context 'when missing user id' do let(:params) { { credit_card_validated_at: credit_card_validated_time } } - it_behaves_like 'returns an error without tracking the exception' + it_behaves_like 'returns an error, tracking the exception' end context 'when unexpected exception happen' do it 'tracks the exception and returns an error' do + logged_params = { + credit_card_validated_at: credit_card_validated_time, + expiration_date: Date.new(expiration_year, 1, 31), + holder_name: "John Smith", + last_digits: 1111, + user_id: user_id + } + expect(::Users::CreditCardValidation).to receive(:upsert).and_raise(e = StandardError.new('My exception!')) - expect(Gitlab::ErrorTracking).to receive(:track_exception).with(e, class: described_class.to_s, params: params) + expect(Gitlab::ErrorTracking).to receive(:track_exception).with(e, class: described_class.to_s, params: logged_params) result = service.execute diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index f9fa46a4fc8..2aebd2adab9 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -392,7 +392,7 @@ RSpec.describe WebHookService do end end - context 'when the hook is throttled (via Redis)', :clean_gitlab_redis_cache do + context 'when the hook is throttled (via Redis)', :clean_gitlab_redis_rate_limiting do before do # Set a high interval to avoid intermittent failures in CI allow(Gitlab::ApplicationRateLimiter).to receive(:rate_limits).and_return( diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index aa791d1d2e7..c8664598691 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -315,6 +315,10 @@ RSpec.configure do |config| # For more information check https://gitlab.com/gitlab-org/gitlab/-/issues/339348 stub_feature_flags(new_header_search: false) + # Disable the override flag in order to enable the feature by default. + # See https://docs.gitlab.com/ee/development/feature_flags/#selectively-disable-by-actor + stub_feature_flags(surface_environment_creation_failure_override: false) + allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged) else unstub_all_feature_flags diff --git a/spec/support/before_all_adapter.rb b/spec/support/before_all_adapter.rb index f48e0f46e80..890bdd6a2c4 100644 --- a/spec/support/before_all_adapter.rb +++ b/spec/support/before_all_adapter.rb @@ -1,25 +1,25 @@ # frozen_string_literal: true class BeforeAllAdapter # rubocop:disable Gitlab/NamespacedClass - def self.all_connection_pools - ::ActiveRecord::Base.connection_handler.all_connection_pools + def self.all_connection_classes + @all_connection_classes ||= [ActiveRecord::Base] + ActiveRecord::Base.descendants.select(&:connection_class?) # rubocop: disable Database/MultipleDatabases end def self.begin_transaction - self.all_connection_pools.each do |connection_pool| - connection_pool.connection.begin_transaction(joinable: false) + self.all_connection_classes.each do |connection_class| + connection_class.connection.begin_transaction(joinable: false) end end def self.rollback_transaction - self.all_connection_pools.each do |connection_pool| - if connection_pool.connection.open_transactions.zero? + self.all_connection_classes.each do |connection_class| + if connection_class.connection.open_transactions.zero? warn "!!! before_all transaction has been already rollbacked and " \ "could work incorrectly" next end - connection_pool.connection.rollback_transaction + connection_class.connection.rollback_transaction end end end diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index 6f96d552da6..ac35662ec93 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -22,7 +22,8 @@ JS_CONSOLE_FILTER = Regexp.union([ '"[WDS] Live Reloading enabled."', 'Download the Vue Devtools extension', 'Download the Apollo DevTools', - "Unrecognized feature: 'interest-cohort'" + "Unrecognized feature: 'interest-cohort'", + 'Does this page need fixes or improvements?' ]) CAPYBARA_WINDOW_SIZE = [1366, 768].freeze diff --git a/spec/support/database/cross-database-modification-allowlist.yml b/spec/support/database/cross-database-modification-allowlist.yml new file mode 100644 index 00000000000..627967f65f3 --- /dev/null +++ b/spec/support/database/cross-database-modification-allowlist.yml @@ -0,0 +1,1343 @@ +- "./ee/spec/controllers/admin/geo/nodes_controller_spec.rb" +- "./ee/spec/controllers/admin/geo/projects_controller_spec.rb" +- "./ee/spec/controllers/admin/projects_controller_spec.rb" +- "./ee/spec/controllers/concerns/internal_redirect_spec.rb" +- "./ee/spec/controllers/ee/projects/jobs_controller_spec.rb" +- "./ee/spec/controllers/oauth/geo_auth_controller_spec.rb" +- "./ee/spec/controllers/projects/approver_groups_controller_spec.rb" +- "./ee/spec/controllers/projects/approvers_controller_spec.rb" +- "./ee/spec/controllers/projects/merge_requests_controller_spec.rb" +- "./ee/spec/controllers/projects/merge_requests/creations_controller_spec.rb" +- "./ee/spec/controllers/projects/settings/access_tokens_controller_spec.rb" +- "./ee/spec/controllers/projects/subscriptions_controller_spec.rb" +- "./ee/spec/features/account_recovery_regular_check_spec.rb" +- "./ee/spec/features/admin/admin_audit_logs_spec.rb" +- "./ee/spec/features/admin/admin_credentials_inventory_spec.rb" +- "./ee/spec/features/admin/admin_dashboard_spec.rb" +- "./ee/spec/features/admin/admin_dev_ops_report_spec.rb" +- "./ee/spec/features/admin/admin_merge_requests_approvals_spec.rb" +- "./ee/spec/features/admin/admin_reset_pipeline_minutes_spec.rb" +- "./ee/spec/features/admin/admin_sends_notification_spec.rb" +- "./ee/spec/features/admin/admin_settings_spec.rb" +- "./ee/spec/features/admin/admin_show_new_user_signups_cap_alert_spec.rb" +- "./ee/spec/features/admin/admin_users_spec.rb" +- "./ee/spec/features/admin/geo/admin_geo_nodes_spec.rb" +- "./ee/spec/features/admin/geo/admin_geo_projects_spec.rb" +- "./ee/spec/features/admin/geo/admin_geo_replication_nav_spec.rb" +- "./ee/spec/features/admin/geo/admin_geo_sidebar_spec.rb" +- "./ee/spec/features/admin/geo/admin_geo_uploads_spec.rb" +- "./ee/spec/features/admin/groups/admin_changes_plan_spec.rb" +- "./ee/spec/features/admin/licenses/admin_uploads_license_spec.rb" +- "./ee/spec/features/admin/licenses/show_user_count_threshold_spec.rb" +- "./ee/spec/features/admin/subscriptions/admin_views_subscription_spec.rb" +- "./ee/spec/features/analytics/code_analytics_spec.rb" +- "./ee/spec/features/billings/billing_plans_spec.rb" +- "./ee/spec/features/billings/extend_reactivate_trial_spec.rb" +- "./ee/spec/features/billings/qrtly_reconciliation_alert_spec.rb" +- "./ee/spec/features/boards/boards_licensed_features_spec.rb" +- "./ee/spec/features/boards/boards_spec.rb" +- "./ee/spec/features/boards/group_boards/board_deletion_spec.rb" +- "./ee/spec/features/boards/group_boards/multiple_boards_spec.rb" +- "./ee/spec/features/boards/new_issue_spec.rb" +- "./ee/spec/features/boards/scoped_issue_board_spec.rb" +- "./ee/spec/features/boards/sidebar_spec.rb" +- "./ee/spec/features/boards/swimlanes/epics_swimlanes_drag_drop_spec.rb" +- "./ee/spec/features/boards/swimlanes/epics_swimlanes_filtering_spec.rb" +- "./ee/spec/features/boards/swimlanes/epics_swimlanes_sidebar_labels_spec.rb" +- "./ee/spec/features/boards/swimlanes/epics_swimlanes_sidebar_spec.rb" +- "./ee/spec/features/boards/swimlanes/epics_swimlanes_spec.rb" +- "./ee/spec/features/boards/user_adds_lists_to_board_spec.rb" +- "./ee/spec/features/boards/user_visits_board_spec.rb" +- "./ee/spec/features/burndown_charts_spec.rb" +- "./ee/spec/features/burnup_charts_spec.rb" +- "./ee/spec/features/ci/ci_minutes_spec.rb" +- "./ee/spec/features/ci_shared_runner_warnings_spec.rb" +- "./ee/spec/features/clusters/create_agent_spec.rb" +- "./ee/spec/features/dashboards/activity_spec.rb" +- "./ee/spec/features/dashboards/groups_spec.rb" +- "./ee/spec/features/dashboards/issues_spec.rb" +- "./ee/spec/features/dashboards/merge_requests_spec.rb" +- "./ee/spec/features/dashboards/operations_spec.rb" +- "./ee/spec/features/dashboards/projects_spec.rb" +- "./ee/spec/features/dashboards/todos_spec.rb" +- "./ee/spec/features/discussion_comments/epic_quick_actions_spec.rb" +- "./ee/spec/features/discussion_comments/epic_spec.rb" +- "./ee/spec/features/epic_boards/epic_boards_sidebar_spec.rb" +- "./ee/spec/features/epic_boards/epic_boards_spec.rb" +- "./ee/spec/features/epic_boards/multiple_epic_boards_spec.rb" +- "./ee/spec/features/epic_boards/new_epic_spec.rb" +- "./ee/spec/features/epics/delete_epic_spec.rb" +- "./ee/spec/features/epics/epic_issues_spec.rb" +- "./ee/spec/features/epics/epic_labels_spec.rb" +- "./ee/spec/features/epics/epic_show_spec.rb" +- "./ee/spec/features/epics/epics_list_spec.rb" +- "./ee/spec/features/epics/filtered_search/visual_tokens_spec.rb" +- "./ee/spec/features/epics/gfm_autocomplete_spec.rb" +- "./ee/spec/features/epics/issue_promotion_spec.rb" +- "./ee/spec/features/epics/referencing_epics_spec.rb" +- "./ee/spec/features/epics/shortcuts_epic_spec.rb" +- "./ee/spec/features/epics/todo_spec.rb" +- "./ee/spec/features/epics/update_epic_spec.rb" +- "./ee/spec/features/epics/user_uses_quick_actions_spec.rb" +- "./ee/spec/features/geo_node_spec.rb" +- "./ee/spec/features/groups/analytics/ci_cd_analytics_spec.rb" +- "./ee/spec/features/groups/analytics/cycle_analytics/charts_spec.rb" +- "./ee/spec/features/groups/analytics/cycle_analytics/filters_and_data_spec.rb" +- "./ee/spec/features/groups/analytics/cycle_analytics/multiple_value_streams_spec.rb" +- "./ee/spec/features/groups/audit_events_spec.rb" +- "./ee/spec/features/groups/billing_spec.rb" +- "./ee/spec/features/groups/contribution_analytics_spec.rb" +- "./ee/spec/features/groups/group_overview_spec.rb" +- "./ee/spec/features/groups/group_roadmap_spec.rb" +- "./ee/spec/features/groups/group_settings_spec.rb" +- "./ee/spec/features/groups/groups_security_credentials_spec.rb" +- "./ee/spec/features/groups/hooks/user_tests_hooks_spec.rb" +- "./ee/spec/features/groups/insights_spec.rb" +- "./ee/spec/features/groups/issues_spec.rb" +- "./ee/spec/features/groups/iterations/iterations_list_spec.rb" +- "./ee/spec/features/groups/iteration_spec.rb" +- "./ee/spec/features/groups/iterations/user_creates_iteration_in_cadence_spec.rb" +- "./ee/spec/features/groups/iterations/user_edits_iteration_cadence_spec.rb" +- "./ee/spec/features/groups/iterations/user_edits_iteration_spec.rb" +- "./ee/spec/features/groups/iterations/user_views_iteration_cadence_spec.rb" +- "./ee/spec/features/groups/iterations/user_views_iteration_spec.rb" +- "./ee/spec/features/groups/ldap_group_links_spec.rb" +- "./ee/spec/features/groups/ldap_settings_spec.rb" +- "./ee/spec/features/groups/members/leave_group_spec.rb" +- "./ee/spec/features/groups/members/list_members_spec.rb" +- "./ee/spec/features/groups/members/override_ldap_memberships_spec.rb" +- "./ee/spec/features/groups/new_spec.rb" +- "./ee/spec/features/groups/push_rules_spec.rb" +- "./ee/spec/features/groups/saml_providers_spec.rb" +- "./ee/spec/features/groups/scim_token_spec.rb" +- "./ee/spec/features/groups/seat_usage/seat_usage_spec.rb" +- "./ee/spec/features/groups/security/compliance_dashboards_spec.rb" +- "./ee/spec/features/groups/settings/user_configures_insights_spec.rb" +- "./ee/spec/features/groups/settings/user_searches_in_settings_spec.rb" +- "./ee/spec/features/groups/sso_spec.rb" +- "./ee/spec/features/groups/wikis_spec.rb" +- "./ee/spec/features/groups/wiki/user_views_wiki_empty_spec.rb" +- "./ee/spec/features/ide/user_commits_changes_spec.rb" +- "./ee/spec/features/ide/user_opens_ide_spec.rb" +- "./ee/spec/features/integrations/jira/jira_issues_list_spec.rb" +- "./ee/spec/features/issues/blocking_issues_spec.rb" +- "./ee/spec/features/issues/epic_in_issue_sidebar_spec.rb" +- "./ee/spec/features/issues/filtered_search/filter_issues_by_iteration_spec.rb" +- "./ee/spec/features/issues/filtered_search/filter_issues_epic_spec.rb" +- "./ee/spec/features/issues/filtered_search/filter_issues_weight_spec.rb" +- "./ee/spec/features/issues/form_spec.rb" +- "./ee/spec/features/issues/gfm_autocomplete_ee_spec.rb" +- "./ee/spec/features/issues/issue_actions_spec.rb" +- "./ee/spec/features/issues/issue_sidebar_spec.rb" +- "./ee/spec/features/issues/move_issue_resource_weight_events_spec.rb" +- "./ee/spec/features/issues/related_issues_spec.rb" +- "./ee/spec/features/issues/resource_weight_events_spec.rb" +- "./ee/spec/features/issues/user_bulk_edits_issues_spec.rb" +- "./ee/spec/features/issues/user_edits_issue_spec.rb" +- "./ee/spec/features/issues/user_uses_quick_actions_spec.rb" +- "./ee/spec/features/issues/user_views_issues_spec.rb" +- "./ee/spec/features/labels_hierarchy_spec.rb" +- "./ee/spec/features/markdown/metrics_spec.rb" +- "./ee/spec/features/merge_requests/user_filters_by_approvers_spec.rb" +- "./ee/spec/features/merge_requests/user_resets_approvers_spec.rb" +- "./ee/spec/features/merge_requests/user_views_all_merge_requests_spec.rb" +- "./ee/spec/features/merge_request/user_approves_with_password_spec.rb" +- "./ee/spec/features/merge_request/user_creates_merge_request_spec.rb" +- "./ee/spec/features/merge_request/user_creates_merge_request_with_blocking_mrs_spec.rb" +- "./ee/spec/features/merge_request/user_creates_multiple_assignees_mr_spec.rb" +- "./ee/spec/features/merge_request/user_creates_multiple_reviewers_mr_spec.rb" +- "./ee/spec/features/merge_request/user_edits_approval_rules_mr_spec.rb" +- "./ee/spec/features/merge_request/user_edits_merge_request_blocking_mrs_spec.rb" +- "./ee/spec/features/merge_request/user_edits_multiple_assignees_mr_spec.rb" +- "./ee/spec/features/merge_request/user_edits_multiple_reviewers_mr_spec.rb" +- "./ee/spec/features/merge_request/user_merges_immediately_spec.rb" +- "./ee/spec/features/merge_request/user_merges_with_push_rules_spec.rb" +- "./ee/spec/features/merge_request/user_sees_approval_widget_spec.rb" +- "./ee/spec/features/merge_request/user_sees_closing_issues_message_spec.rb" +- "./ee/spec/features/merge_request/user_sees_merge_widget_spec.rb" +- "./ee/spec/features/merge_request/user_sees_status_checks_widget_spec.rb" +- "./ee/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb" +- "./ee/spec/features/merge_request/user_sets_approval_rules_spec.rb" +- "./ee/spec/features/merge_request/user_sets_approvers_spec.rb" +- "./ee/spec/features/merge_request/user_uses_slash_commands_spec.rb" +- "./ee/spec/features/merge_request/user_views_blocked_merge_request_spec.rb" +- "./ee/spec/features/merge_trains/user_adds_merge_request_to_merge_train_spec.rb" +- "./ee/spec/features/merge_trains/user_adds_to_merge_train_when_pipeline_succeeds_spec.rb" +- "./ee/spec/features/oncall_schedules/user_creates_schedule_spec.rb" +- "./ee/spec/features/operations_nav_link_spec.rb" +- "./ee/spec/features/profiles/account_spec.rb" +- "./ee/spec/features/profiles/billing_spec.rb" +- "./ee/spec/features/projects/audit_events_spec.rb" +- "./ee/spec/features/projects/cluster_agents_spec.rb" +- "./ee/spec/features/projects/custom_projects_template_spec.rb" +- "./ee/spec/features/projects/environments/environments_spec.rb" +- "./ee/spec/features/projects/feature_flags/feature_flag_issues_spec.rb" +- "./ee/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb" +- "./ee/spec/features/projects/feature_flags/user_deletes_feature_flag_spec.rb" +- "./ee/spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb" +- "./ee/spec/features/projects/insights_spec.rb" +- "./ee/spec/features/projects/integrations/user_activates_jira_spec.rb" +- "./ee/spec/features/projects/issues/user_creates_issue_spec.rb" +- "./ee/spec/features/projects/iterations/iteration_cadences_list_spec.rb" +- "./ee/spec/features/projects/iterations/iterations_list_spec.rb" +- "./ee/spec/features/projects/iterations/user_views_iteration_spec.rb" +- "./ee/spec/features/projects/jobs_spec.rb" +- "./ee/spec/features/projects/kerberos_clone_instructions_spec.rb" +- "./ee/spec/features/projects/licenses/maintainer_views_policies_spec.rb" +- "./ee/spec/features/projects/members/member_is_removed_from_project_spec.rb" +- "./ee/spec/features/projects/merge_requests/user_approves_merge_request_spec.rb" +- "./ee/spec/features/projects/merge_requests/user_edits_merge_request_spec.rb" +- "./ee/spec/features/projects/mirror_spec.rb" +- "./ee/spec/features/projects/new_project_from_template_spec.rb" +- "./ee/spec/features/projects/new_project_spec.rb" +- "./ee/spec/features/projects/path_locks_spec.rb" +- "./ee/spec/features/projects/pipelines/pipeline_spec.rb" +- "./ee/spec/features/projects/push_rules_spec.rb" +- "./ee/spec/features/projects/quality/test_case_create_spec.rb" +- "./ee/spec/features/projects/quality/test_case_list_spec.rb" +- "./ee/spec/features/projects/quality/test_case_show_spec.rb" +- "./ee/spec/features/projects/releases/user_views_release_spec.rb" +- "./ee/spec/features/projects/requirements_management/requirements_list_spec.rb" +- "./ee/spec/features/projects/security/dast_scanner_profiles_spec.rb" +- "./ee/spec/features/projects/security/dast_site_profiles_spec.rb" +- "./ee/spec/features/projects/security/user_creates_on_demand_scan_spec.rb" +- "./ee/spec/features/projects/security/user_views_security_configuration_spec.rb" +- "./ee/spec/features/projects/services/prometheus_custom_metrics_spec.rb" +- "./ee/spec/features/projects/services/user_activates_github_spec.rb" +- "./ee/spec/features/projects/settings/disable_merge_trains_setting_spec.rb" +- "./ee/spec/features/projects/settings/ee/repository_mirrors_settings_spec.rb" +- "./ee/spec/features/projects/settings/ee/service_desk_setting_spec.rb" +- "./ee/spec/features/projects/settings/issues_settings_spec.rb" +- "./ee/spec/features/projects/settings/merge_request_approvals_settings_spec.rb" +- "./ee/spec/features/projects/settings/merge_requests_settings_spec.rb" +- "./ee/spec/features/projects/settings/pipeline_subscriptions_spec.rb" +- "./ee/spec/features/projects/settings/protected_environments_spec.rb" +- "./ee/spec/features/projects/settings/user_manages_merge_pipelines_spec.rb" +- "./ee/spec/features/projects/settings/user_manages_merge_trains_spec.rb" +- "./ee/spec/features/projects_spec.rb" +- "./ee/spec/features/projects/user_applies_custom_file_template_spec.rb" +- "./ee/spec/features/projects/view_blob_with_code_owners_spec.rb" +- "./ee/spec/features/projects/wiki/user_views_wiki_empty_spec.rb" +- "./ee/spec/features/promotion_spec.rb" +- "./ee/spec/features/protected_branches_spec.rb" +- "./ee/spec/features/protected_tags_spec.rb" +- "./ee/spec/features/registrations/combined_registration_spec.rb" +- "./ee/spec/features/registrations/trial_during_signup_flow_spec.rb" +- "./ee/spec/features/registrations/user_sees_new_onboarding_flow_spec.rb" +- "./ee/spec/features/registrations/welcome_spec.rb" +- "./ee/spec/features/search/elastic/global_search_spec.rb" +- "./ee/spec/features/search/elastic/group_search_spec.rb" +- "./ee/spec/features/search/elastic/project_search_spec.rb" +- "./ee/spec/features/search/elastic/snippet_search_spec.rb" +- "./ee/spec/features/search/user_searches_for_epics_spec.rb" +- "./ee/spec/features/subscriptions/groups/edit_spec.rb" +- "./ee/spec/features/trial_registrations/signup_spec.rb" +- "./ee/spec/features/trials/capture_lead_spec.rb" +- "./ee/spec/features/trials/select_namespace_spec.rb" +- "./ee/spec/features/trials/show_trial_banner_spec.rb" +- "./ee/spec/features/users/login_spec.rb" +- "./ee/spec/finders/geo/attachment_legacy_registry_finder_spec.rb" +- "./ee/spec/finders/geo/container_repository_registry_finder_spec.rb" +- "./ee/spec/finders/geo/lfs_object_registry_finder_spec.rb" +- "./ee/spec/finders/geo/merge_request_diff_registry_finder_spec.rb" +- "./ee/spec/finders/geo/package_file_registry_finder_spec.rb" +- "./ee/spec/finders/geo/pages_deployment_registry_finder_spec.rb" +- "./ee/spec/finders/geo/pipeline_artifact_registry_finder_spec.rb" +- "./ee/spec/finders/geo/project_registry_finder_spec.rb" +- "./ee/spec/finders/merge_requests/by_approvers_finder_spec.rb" +- "./ee/spec/frontend/fixtures/analytics/value_streams.rb" +- "./ee/spec/graphql/mutations/dast_on_demand_scans/create_spec.rb" +- "./ee/spec/graphql/mutations/dast/profiles/create_spec.rb" +- "./ee/spec/graphql/mutations/dast/profiles/run_spec.rb" +- "./ee/spec/graphql/mutations/dast/profiles/update_spec.rb" +- "./ee/spec/graphql/mutations/merge_requests/accept_spec.rb" +- "./ee/spec/graphql/resolvers/geo/group_wiki_repository_registries_resolver_spec.rb" +- "./ee/spec/graphql/resolvers/geo/lfs_object_registries_resolver_spec.rb" +- "./ee/spec/graphql/resolvers/geo/merge_request_diff_registries_resolver_spec.rb" +- "./ee/spec/graphql/resolvers/geo/package_file_registries_resolver_spec.rb" +- "./ee/spec/graphql/resolvers/geo/pages_deployment_registries_resolver_spec.rb" +- "./ee/spec/graphql/resolvers/geo/pipeline_artifact_registries_resolver_spec.rb" +- "./ee/spec/graphql/resolvers/geo/snippet_repository_registries_resolver_spec.rb" +- "./ee/spec/graphql/resolvers/geo/terraform_state_version_registries_resolver_spec.rb" +- "./ee/spec/graphql/resolvers/geo/upload_registries_resolver_spec.rb" +- "./ee/spec/helpers/application_helper_spec.rb" +- "./ee/spec/helpers/ee/geo_helper_spec.rb" +- "./ee/spec/lib/analytics/devops_adoption/snapshot_calculator_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/backfill_iteration_cadence_id_for_boards_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/backfill_version_data_from_gitaly_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/create_security_setting_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/fix_ruby_object_in_audit_events_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/migrate_approver_to_approval_rules_check_progress_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/migrate_approver_to_approval_rules_in_batch_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/migrate_approver_to_approval_rules_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/migrate_devops_segments_to_groups_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/migrate_security_scans_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/move_epic_issues_after_epics_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/populate_any_approval_rule_for_merge_requests_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/populate_any_approval_rule_for_projects_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/populate_latest_pipeline_ids_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/populate_namespace_statistics_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/populate_resolved_on_default_branch_column_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/populate_uuids_for_security_findings_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/populate_vulnerability_feedback_pipeline_id_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/populate_vulnerability_historical_statistics_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/prune_orphaned_geo_events_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/remove_duplicate_cs_findings_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/remove_duplicated_cs_findings_without_vulnerability_id_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/remove_inaccessible_epic_todos_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/remove_undefined_occurrence_confidence_level_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/remove_undefined_occurrence_severity_level_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/remove_undefined_vulnerability_confidence_level_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/remove_undefined_vulnerability_severity_level_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/update_location_fingerprint_for_container_scanning_findings_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/update_vulnerabilities_from_dismissal_feedback_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/update_vulnerabilities_to_dismissed_spec.rb" +- "./ee/spec/lib/ee/gitlab/background_migration/update_vulnerability_confidence_spec.rb" +- "./ee/spec/lib/ee/gitlab/database/connection_spec.rb" +- "./ee/spec/lib/ee/gitlab/database_spec.rb" +- "./ee/spec/lib/ee/gitlab/middleware/read_only_spec.rb" +- "./ee/spec/lib/ee/gitlab/usage_data_spec.rb" +- "./ee/spec/lib/gitlab/background_migration/fix_orphan_promoted_issues_spec.rb" +- "./ee/spec/lib/gitlab/background_migration/user_mentions/create_resource_user_mention_spec.rb" +- "./ee/spec/lib/gitlab/ci/templates/Jobs/dast_default_branch_gitlab_ci_yaml_spec.rb" +- "./ee/spec/lib/gitlab/geo/base_request_spec.rb" +- "./ee/spec/lib/gitlab/geo/database_tasks_spec.rb" +- "./ee/spec/lib/gitlab/geo/event_gap_tracking_spec.rb" +- "./ee/spec/lib/gitlab/geo/geo_tasks_spec.rb" +- "./ee/spec/lib/gitlab/geo/jwt_request_decoder_spec.rb" +- "./ee/spec/lib/gitlab/geo/log_cursor/events/design_repository_updated_event_spec.rb" +- "./ee/spec/lib/gitlab/geo/log_cursor/events/job_artifact_deleted_event_spec.rb" +- "./ee/spec/lib/gitlab/geo/log_cursor/events/repository_created_event_spec.rb" +- "./ee/spec/lib/gitlab/geo/log_cursor/events/repository_updated_event_spec.rb" +- "./ee/spec/lib/gitlab/geo/oauth/login_state_spec.rb" +- "./ee/spec/lib/gitlab/geo/oauth/logout_token_spec.rb" +- "./ee/spec/lib/gitlab/geo/oauth/session_spec.rb" +- "./ee/spec/lib/gitlab/geo/registry_batcher_spec.rb" +- "./ee/spec/lib/gitlab/geo/replicable_model_spec.rb" +- "./ee/spec/lib/gitlab/geo/replication/blob_downloader_spec.rb" +- "./ee/spec/lib/gitlab/geo/replication/file_transfer_spec.rb" +- "./ee/spec/lib/gitlab/geo/replicator_spec.rb" +- "./ee/spec/lib/gitlab/git_access_spec.rb" +- "./ee/spec/lib/pseudonymizer/dumper_spec.rb" +- "./ee/spec/lib/system_check/geo/geo_database_configured_check_spec.rb" +- "./ee/spec/lib/system_check/geo/http_connection_check_spec.rb" +- "./ee/spec/lib/system_check/rake_task/geo_task_spec.rb" +- "./ee/spec/mailers/notify_spec.rb" +- "./ee/spec/migrations/20190926180443_schedule_epic_issues_after_epics_move_spec.rb" +- "./ee/spec/migrations/add_non_null_constraint_for_escalation_rule_on_pending_alert_escalations_spec.rb" +- "./ee/spec/migrations/add_unique_constraint_to_software_licenses_spec.rb" +- "./ee/spec/migrations/backfill_namespace_statistics_with_wiki_size_spec.rb" +- "./ee/spec/migrations/backfill_operations_feature_flags_iid_spec.rb" +- "./ee/spec/migrations/backfill_software_licenses_spdx_identifiers_spec.rb" +- "./ee/spec/migrations/backfill_version_author_and_created_at_spec.rb" +- "./ee/spec/migrations/cleanup_deploy_access_levels_for_removed_groups_spec.rb" +- "./ee/spec/migrations/create_elastic_reindexing_subtasks_spec.rb" +- "./ee/spec/migrations/fix_any_approver_rule_for_projects_spec.rb" +- "./ee/spec/migrations/migrate_design_notes_mentions_to_db_spec.rb" +- "./ee/spec/migrations/migrate_epic_mentions_to_db_spec.rb" +- "./ee/spec/migrations/migrate_epic_notes_mentions_to_db_spec.rb" +- "./ee/spec/migrations/migrate_license_management_artifacts_to_license_scanning_spec.rb" +- "./ee/spec/migrations/migrate_saml_identities_to_scim_identities_spec.rb" +- "./ee/spec/migrations/migrate_scim_identities_to_saml_for_new_users_spec.rb" +- "./ee/spec/migrations/migrate_vulnerability_dismissal_feedback_spec.rb" +- "./ee/spec/migrations/migrate_vulnerability_dismissals_spec.rb" +- "./ee/spec/migrations/nullify_feature_flag_plaintext_tokens_spec.rb" +- "./ee/spec/migrations/populate_vulnerability_historical_statistics_for_year_spec.rb" +- "./ee/spec/migrations/remove_creations_in_gitlab_subscription_histories_spec.rb" +- "./ee/spec/migrations/remove_cycle_analytics_total_stage_data_spec.rb" +- "./ee/spec/migrations/remove_duplicated_cs_findings_spec.rb" +- "./ee/spec/migrations/remove_duplicated_cs_findings_without_vulnerability_id_spec.rb" +- "./ee/spec/migrations/remove_schedule_and_status_null_constraints_from_pending_escalations_alert_spec.rb" +- "./ee/spec/migrations/schedule_fix_orphan_promoted_issues_spec.rb" +- "./ee/spec/migrations/schedule_fix_ruby_object_in_audit_events_spec.rb" +- "./ee/spec/migrations/schedule_merge_request_any_approval_rule_migration_spec.rb" +- "./ee/spec/migrations/schedule_populate_dismissed_state_for_vulnerabilities_spec.rb" +- "./ee/spec/migrations/schedule_populate_resolved_on_default_branch_column_spec.rb" +- "./ee/spec/migrations/schedule_populate_vulnerability_historical_statistics_spec.rb" +- "./ee/spec/migrations/schedule_project_any_approval_rule_migration_spec.rb" +- "./ee/spec/migrations/schedule_remove_inaccessible_epic_todos_spec.rb" +- "./ee/spec/migrations/schedule_sync_blocking_issues_count_spec.rb" +- "./ee/spec/migrations/schedule_uuid_population_for_security_findings2_spec.rb" +- "./ee/spec/migrations/set_report_type_for_vulnerabilities_spec.rb" +- "./ee/spec/migrations/set_resolved_state_on_vulnerabilities_spec.rb" +- "./ee/spec/migrations/update_cs_vulnerability_confidence_column_spec.rb" +- "./ee/spec/migrations/update_gitlab_subscriptions_start_at_post_eoa_spec.rb" +- "./ee/spec/migrations/update_location_fingerprint_column_for_cs_spec.rb" +- "./ee/spec/migrations/update_occurrence_severity_column_spec.rb" +- "./ee/spec/migrations/update_undefined_confidence_from_occurrences_spec.rb" +- "./ee/spec/migrations/update_undefined_confidence_from_vulnerabilities_spec.rb" +- "./ee/spec/migrations/update_vulnerability_severity_column_spec.rb" +- "./ee/spec/models/analytics/cycle_analytics/group_level_spec.rb" +- "./ee/spec/models/approval_merge_request_rule_spec.rb" +- "./ee/spec/models/approval_project_rule_spec.rb" +- "./ee/spec/models/approval_state_spec.rb" +- "./ee/spec/models/approval_wrapped_code_owner_rule_spec.rb" +- "./ee/spec/models/approval_wrapped_rule_spec.rb" +- "./ee/spec/models/approver_group_spec.rb" +- "./ee/spec/models/ci/bridge_spec.rb" +- "./ee/spec/models/ci/build_spec.rb" +- "./ee/spec/models/ci/minutes/additional_pack_spec.rb" +- "./ee/spec/models/ci/pipeline_spec.rb" +- "./ee/spec/models/ci/subscriptions/project_spec.rb" +- "./ee/spec/models/concerns/approval_rule_like_spec.rb" +- "./ee/spec/models/concerns/approver_migrate_hook_spec.rb" +- "./ee/spec/models/dora/daily_metrics_spec.rb" +- "./ee/spec/models/ee/ci/job_artifact_spec.rb" +- "./ee/spec/models/ee/ci/pipeline_artifact_spec.rb" +- "./ee/spec/models/ee/ci/runner_spec.rb" +- "./ee/spec/models/ee/merge_request_diff_spec.rb" +- "./ee/spec/models/ee/pages_deployment_spec.rb" +- "./ee/spec/models/ee/terraform/state_version_spec.rb" +- "./ee/spec/models/geo/container_repository_registry_spec.rb" +- "./ee/spec/models/geo/deleted_project_spec.rb" +- "./ee/spec/models/geo/design_registry_spec.rb" +- "./ee/spec/models/geo/job_artifact_registry_spec.rb" +- "./ee/spec/models/geo_node_namespace_link_spec.rb" +- "./ee/spec/models/geo_node_spec.rb" +- "./ee/spec/models/geo_node_status_spec.rb" +- "./ee/spec/models/geo/package_file_registry_spec.rb" +- "./ee/spec/models/geo/project_registry_spec.rb" +- "./ee/spec/models/group_member_spec.rb" +- "./ee/spec/models/group_wiki_repository_spec.rb" +- "./ee/spec/models/merge_request_spec.rb" +- "./ee/spec/models/packages/package_file_spec.rb" +- "./ee/spec/models/project_spec.rb" +- "./ee/spec/models/requirements_management/requirement_spec.rb" +- "./ee/spec/models/snippet_repository_spec.rb" +- "./ee/spec/models/upload_spec.rb" +- "./ee/spec/models/visible_approvable_spec.rb" +- "./ee/spec/policies/ci/build_policy_spec.rb" +- "./ee/spec/presenters/approval_rule_presenter_spec.rb" +- "./ee/spec/presenters/merge_request_presenter_spec.rb" +- "./ee/spec/replicators/geo/pipeline_artifact_replicator_spec.rb" +- "./ee/spec/replicators/geo/terraform_state_version_replicator_spec.rb" +- "./ee/spec/requests/api/ci/pipelines_spec.rb" +- "./ee/spec/requests/api/geo_nodes_spec.rb" +- "./ee/spec/requests/api/geo_replication_spec.rb" +- "./ee/spec/requests/api/graphql/mutations/dast_on_demand_scans/create_spec.rb" +- "./ee/spec/requests/api/graphql/mutations/dast/profiles/create_spec.rb" +- "./ee/spec/requests/api/graphql/mutations/dast/profiles/run_spec.rb" +- "./ee/spec/requests/api/graphql/mutations/dast/profiles/update_spec.rb" +- "./ee/spec/requests/api/graphql/project/pipeline/dast_profile_spec.rb" +- "./ee/spec/requests/api/merge_request_approval_rules_spec.rb" +- "./ee/spec/requests/api/merge_requests_spec.rb" +- "./ee/spec/requests/api/project_approval_rules_spec.rb" +- "./ee/spec/requests/api/project_approval_settings_spec.rb" +- "./ee/spec/requests/api/project_approvals_spec.rb" +- "./ee/spec/requests/api/project_snapshots_spec.rb" +- "./ee/spec/requests/api/status_checks_spec.rb" +- "./ee/spec/requests/api/vulnerability_findings_spec.rb" +- "./ee/spec/requests/projects/merge_requests_controller_spec.rb" +- "./ee/spec/routing/admin_routing_spec.rb" +- "./ee/spec/serializers/dashboard_operations_project_entity_spec.rb" +- "./ee/spec/serializers/ee/evidences/release_entity_spec.rb" +- "./ee/spec/serializers/ee/user_serializer_spec.rb" +- "./ee/spec/serializers/evidences/evidence_entity_spec.rb" +- "./ee/spec/serializers/merge_request_widget_entity_spec.rb" +- "./ee/spec/serializers/pipeline_serializer_spec.rb" +- "./ee/spec/services/approval_rules/create_service_spec.rb" +- "./ee/spec/services/approval_rules/finalize_service_spec.rb" +- "./ee/spec/services/approval_rules/merge_request_rule_destroy_service_spec.rb" +- "./ee/spec/services/approval_rules/params_filtering_service_spec.rb" +- "./ee/spec/services/approval_rules/project_rule_destroy_service_spec.rb" +- "./ee/spec/services/approval_rules/update_service_spec.rb" +- "./ee/spec/services/app_sec/dast/profiles/create_service_spec.rb" +- "./ee/spec/services/app_sec/dast/profiles/update_service_spec.rb" +- "./ee/spec/services/app_sec/dast/scans/create_service_spec.rb" +- "./ee/spec/services/app_sec/dast/scans/run_service_spec.rb" +- "./ee/spec/services/ci/compare_license_scanning_reports_service_spec.rb" +- "./ee/spec/services/ci/compare_metrics_reports_service_spec.rb" +- "./ee/spec/services/ci/create_pipeline_service/dast_configuration_spec.rb" +- "./ee/spec/services/ci/destroy_pipeline_service_spec.rb" +- "./ee/spec/services/ci/minutes/track_live_consumption_service_spec.rb" +- "./ee/spec/services/ci/minutes/update_build_minutes_service_spec.rb" +- "./ee/spec/services/ci/register_job_service_spec.rb" +- "./ee/spec/services/ci/retry_build_service_spec.rb" +- "./ee/spec/services/ci/run_dast_scan_service_spec.rb" +- "./ee/spec/services/ci/subscribe_bridge_service_spec.rb" +- "./ee/spec/services/ci/sync_reports_to_approval_rules_service_spec.rb" +- "./ee/spec/services/ci/trigger_downstream_subscription_service_spec.rb" +- "./ee/spec/services/dast_on_demand_scans/create_service_spec.rb" +- "./ee/spec/services/deployments/auto_rollback_service_spec.rb" +- "./ee/spec/services/ee/ci/job_artifacts/destroy_all_expired_service_spec.rb" +- "./ee/spec/services/ee/ci/job_artifacts/destroy_batch_service_spec.rb" +- "./ee/spec/services/ee/integrations/test/project_service_spec.rb" +- "./ee/spec/services/ee/issuable/destroy_service_spec.rb" +- "./ee/spec/services/ee/merge_requests/refresh_service_spec.rb" +- "./ee/spec/services/ee/merge_requests/update_service_spec.rb" +- "./ee/spec/services/ee/notification_service_spec.rb" +- "./ee/spec/services/ee/post_receive_service_spec.rb" +- "./ee/spec/services/ee/releases/create_evidence_service_spec.rb" +- "./ee/spec/services/ee/users/destroy_service_spec.rb" +- "./ee/spec/services/external_status_checks/create_service_spec.rb" +- "./ee/spec/services/external_status_checks/destroy_service_spec.rb" +- "./ee/spec/services/external_status_checks/update_service_spec.rb" +- "./ee/spec/services/geo/container_repository_sync_service_spec.rb" +- "./ee/spec/services/geo/hashed_storage_migrated_event_store_spec.rb" +- "./ee/spec/services/geo/hashed_storage_migration_service_spec.rb" +- "./ee/spec/services/geo/node_create_service_spec.rb" +- "./ee/spec/services/geo/node_status_request_service_spec.rb" +- "./ee/spec/services/geo/node_update_service_spec.rb" +- "./ee/spec/services/geo/project_housekeeping_service_spec.rb" +- "./ee/spec/services/geo/registry_consistency_service_spec.rb" +- "./ee/spec/services/geo/repositories_changed_event_store_spec.rb" +- "./ee/spec/services/geo/repository_updated_event_store_spec.rb" +- "./ee/spec/services/geo/repository_verification_reset_spec.rb" +- "./ee/spec/services/geo/repository_verification_secondary_service_spec.rb" +- "./ee/spec/services/merge_requests/merge_service_spec.rb" +- "./ee/spec/services/merge_requests/reset_approvals_service_spec.rb" +- "./ee/spec/services/merge_requests/sync_report_approver_approval_rules_spec.rb" +- "./ee/spec/services/projects/transfer_service_spec.rb" +- "./ee/spec/services/security/security_orchestration_policies/rule_schedule_service_spec.rb" +- "./ee/spec/services/todo_service_spec.rb" +- "./ee/spec/services/vulnerability_feedback/create_service_spec.rb" +- "./ee/spec/services/wiki_pages/create_service_spec.rb" +- "./ee/spec/services/wiki_pages/destroy_service_spec.rb" +- "./ee/spec/services/wiki_pages/update_service_spec.rb" +- "./ee/spec/support/shared_examples/fixtures/analytics_value_streams_shared_examples.rb" +- "./ee/spec/support/shared_examples/graphql/geo/geo_registries_resolver_shared_examples.rb" +- "./ee/spec/support/shared_examples/graphql/mutations/dast_on_demand_scans_shared_examples.rb" +- "./ee/spec/support/shared_examples/graphql/mutations/dast_on_demand_scan_with_user_abilities_shared_examples.rb" +- "./ee/spec/support/shared_examples/lib/gitlab/geo/geo_log_cursor_event_shared_examples.rb" +- "./ee/spec/support/shared_examples/lib/gitlab/geo/geo_logs_event_source_info_shared_examples.rb" +- "./ee/spec/support/shared_examples/models/concerns/blob_replicator_strategy_shared_examples.rb" +- "./ee/spec/support/shared_examples/models/concerns/replicable_model_shared_examples.rb" +- "./ee/spec/support/shared_examples/models/concerns/verifiable_replicator_shared_examples.rb" +- "./ee/spec/support/shared_examples/policies/protected_environments_shared_examples.rb" +- "./ee/spec/support/shared_examples/requests/api/project_approval_rules_api_shared_examples.rb" +- "./ee/spec/support/shared_examples/services/audit_event_logging_shared_examples.rb" +- "./ee/spec/support/shared_examples/services/build_execute_shared_examples.rb" +- "./ee/spec/support/shared_examples/services/dast_on_demand_scans_shared_examples.rb" +- "./ee/spec/support/shared_examples/services/geo_event_store_shared_examples.rb" +- "./ee/spec/tasks/geo_rake_spec.rb" +- "./ee/spec/tasks/gitlab/geo_rake_spec.rb" +- "./ee/spec/workers/geo/file_download_dispatch_worker_spec.rb" +- "./ee/spec/workers/geo/metrics_update_worker_spec.rb" +- "./ee/spec/workers/geo/prune_event_log_worker_spec.rb" +- "./ee/spec/workers/geo/registry_sync_worker_spec.rb" +- "./ee/spec/workers/geo/repository_cleanup_worker_spec.rb" +- "./ee/spec/workers/geo/repository_sync_worker_spec.rb" +- "./ee/spec/workers/geo/repository_verification/secondary/scheduler_worker_spec.rb" +- "./ee/spec/workers/geo/repository_verification/secondary/single_worker_spec.rb" +- "./ee/spec/workers/geo/verification_worker_spec.rb" +- "./ee/spec/workers/refresh_license_compliance_checks_worker_spec.rb" +- "./spec/controllers/abuse_reports_controller_spec.rb" +- "./spec/controllers/admin/spam_logs_controller_spec.rb" +- "./spec/controllers/admin/users_controller_spec.rb" +- "./spec/controllers/omniauth_callbacks_controller_spec.rb" +- "./spec/controllers/projects/issues_controller_spec.rb" +- "./spec/controllers/projects/jobs_controller_spec.rb" +- "./spec/controllers/projects/merge_requests/content_controller_spec.rb" +- "./spec/controllers/projects/merge_requests_controller_spec.rb" +- "./spec/controllers/projects/pipelines_controller_spec.rb" +- "./spec/controllers/projects/pipelines/tests_controller_spec.rb" +- "./spec/controllers/projects/settings/access_tokens_controller_spec.rb" +- "./spec/controllers/projects/tags_controller_spec.rb" +- "./spec/controllers/sent_notifications_controller_spec.rb" +- "./spec/factories_spec.rb" +- "./spec/features/action_cable_logging_spec.rb" +- "./spec/features/admin/admin_abuse_reports_spec.rb" +- "./spec/features/admin/admin_appearance_spec.rb" +- "./spec/features/admin/admin_broadcast_messages_spec.rb" +- "./spec/features/admin/admin_builds_spec.rb" +- "./spec/features/admin/admin_dev_ops_report_spec.rb" +- "./spec/features/admin/admin_disables_git_access_protocol_spec.rb" +- "./spec/features/admin/admin_disables_two_factor_spec.rb" +- "./spec/features/admin/admin_groups_spec.rb" +- "./spec/features/admin/admin_hooks_spec.rb" +- "./spec/features/admin/admin_labels_spec.rb" +- "./spec/features/admin/admin_mode/login_spec.rb" +- "./spec/features/admin/admin_mode/logout_spec.rb" +- "./spec/features/admin/admin_mode_spec.rb" +- "./spec/features/admin/admin_mode/workers_spec.rb" +- "./spec/features/admin/admin_projects_spec.rb" +- "./spec/features/admin/admin_runners_spec.rb" +- "./spec/features/admin/admin_search_settings_spec.rb" +- "./spec/features/admin/admin_serverless_domains_spec.rb" +- "./spec/features/admin/admin_settings_spec.rb" +- "./spec/features/admin/admin_users_impersonation_tokens_spec.rb" +- "./spec/features/admin/admin_uses_repository_checks_spec.rb" +- "./spec/features/admin/clusters/eks_spec.rb" +- "./spec/features/admin/dashboard_spec.rb" +- "./spec/features/admin/integrations/user_activates_mattermost_slash_command_spec.rb" +- "./spec/features/admin/users/user_spec.rb" +- "./spec/features/admin/users/users_spec.rb" +- "./spec/features/alert_management/alert_details_spec.rb" +- "./spec/features/alert_management/alert_management_list_spec.rb" +- "./spec/features/alert_management_spec.rb" +- "./spec/features/alert_management/user_filters_alerts_by_status_spec.rb" +- "./spec/features/alert_management/user_searches_alerts_spec.rb" +- "./spec/features/alert_management/user_updates_alert_status_spec.rb" +- "./spec/features/alerts_settings/user_views_alerts_settings_spec.rb" +- "./spec/features/atom/dashboard_spec.rb" +- "./spec/features/boards/boards_spec.rb" +- "./spec/features/boards/focus_mode_spec.rb" +- "./spec/features/boards/issue_ordering_spec.rb" +- "./spec/features/boards/keyboard_shortcut_spec.rb" +- "./spec/features/boards/multiple_boards_spec.rb" +- "./spec/features/boards/new_issue_spec.rb" +- "./spec/features/boards/reload_boards_on_browser_back_spec.rb" +- "./spec/features/boards/sidebar_due_date_spec.rb" +- "./spec/features/boards/sidebar_labels_in_namespaces_spec.rb" +- "./spec/features/boards/sidebar_labels_spec.rb" +- "./spec/features/boards/sidebar_milestones_spec.rb" +- "./spec/features/boards/sidebar_spec.rb" +- "./spec/features/boards/user_adds_lists_to_board_spec.rb" +- "./spec/features/boards/user_visits_board_spec.rb" +- "./spec/features/broadcast_messages_spec.rb" +- "./spec/features/calendar_spec.rb" +- "./spec/features/callouts/registration_enabled_spec.rb" +- "./spec/features/clusters/cluster_detail_page_spec.rb" +- "./spec/features/clusters/cluster_health_dashboard_spec.rb" +- "./spec/features/commit_spec.rb" +- "./spec/features/commits_spec.rb" +- "./spec/features/commits/user_uses_quick_actions_spec.rb" +- "./spec/features/contextual_sidebar_spec.rb" +- "./spec/features/cycle_analytics_spec.rb" +- "./spec/features/dashboard/activity_spec.rb" +- "./spec/features/dashboard/archived_projects_spec.rb" +- "./spec/features/dashboard/datetime_on_tooltips_spec.rb" +- "./spec/features/dashboard/group_dashboard_with_external_authorization_service_spec.rb" +- "./spec/features/dashboard/groups_list_spec.rb" +- "./spec/features/dashboard/group_spec.rb" +- "./spec/features/dashboard/issues_filter_spec.rb" +- "./spec/features/dashboard/issues_spec.rb" +- "./spec/features/dashboard/label_filter_spec.rb" +- "./spec/features/dashboard/merge_requests_spec.rb" +- "./spec/features/dashboard/milestones_spec.rb" +- "./spec/features/dashboard/project_member_activity_index_spec.rb" +- "./spec/features/dashboard/projects_spec.rb" +- "./spec/features/dashboard/root_spec.rb" +- "./spec/features/dashboard/shortcuts_spec.rb" +- "./spec/features/dashboard/snippets_spec.rb" +- "./spec/features/dashboard/todos/todos_filtering_spec.rb" +- "./spec/features/dashboard/todos/todos_spec.rb" +- "./spec/features/dashboard/user_filters_projects_spec.rb" +- "./spec/features/discussion_comments/commit_spec.rb" +- "./spec/features/discussion_comments/issue_spec.rb" +- "./spec/features/discussion_comments/merge_request_spec.rb" +- "./spec/features/discussion_comments/snippets_spec.rb" +- "./spec/features/error_pages_spec.rb" +- "./spec/features/error_tracking/user_filters_errors_by_status_spec.rb" +- "./spec/features/error_tracking/user_searches_sentry_errors_spec.rb" +- "./spec/features/error_tracking/user_sees_error_details_spec.rb" +- "./spec/features/error_tracking/user_sees_error_index_spec.rb" +- "./spec/features/expand_collapse_diffs_spec.rb" +- "./spec/features/explore/groups_list_spec.rb" +- "./spec/features/explore/groups_spec.rb" +- "./spec/features/explore/user_explores_projects_spec.rb" +- "./spec/features/file_uploads/attachment_spec.rb" +- "./spec/features/file_uploads/ci_artifact_spec.rb" +- "./spec/features/file_uploads/git_lfs_spec.rb" +- "./spec/features/file_uploads/graphql_add_design_spec.rb" +- "./spec/features/file_uploads/group_import_spec.rb" +- "./spec/features/file_uploads/maven_package_spec.rb" +- "./spec/features/file_uploads/multipart_invalid_uploads_spec.rb" +- "./spec/features/file_uploads/nuget_package_spec.rb" +- "./spec/features/file_uploads/project_import_spec.rb" +- "./spec/features/file_uploads/rubygem_package_spec.rb" +- "./spec/features/file_uploads/user_avatar_spec.rb" +- "./spec/features/frequently_visited_projects_and_groups_spec.rb" +- "./spec/features/gitlab_experiments_spec.rb" +- "./spec/features/global_search_spec.rb" +- "./spec/features/groups/activity_spec.rb" +- "./spec/features/groups/board_sidebar_spec.rb" +- "./spec/features/groups/board_spec.rb" +- "./spec/features/groups/clusters/eks_spec.rb" +- "./spec/features/groups/clusters/user_spec.rb" +- "./spec/features/groups/container_registry_spec.rb" +- "./spec/features/groups/dependency_proxy_spec.rb" +- "./spec/features/groups/empty_states_spec.rb" +- "./spec/features/groups/import_export/connect_instance_spec.rb" +- "./spec/features/groups/import_export/export_file_spec.rb" +- "./spec/features/groups/import_export/import_file_spec.rb" +- "./spec/features/groups/integrations/user_activates_mattermost_slash_command_spec.rb" +- "./spec/features/groups/issues_spec.rb" +- "./spec/features/groups/labels/index_spec.rb" +- "./spec/features/groups/labels/search_labels_spec.rb" +- "./spec/features/groups/labels/sort_labels_spec.rb" +- "./spec/features/groups/labels/subscription_spec.rb" +- "./spec/features/groups/members/filter_members_spec.rb" +- "./spec/features/groups/members/leave_group_spec.rb" +- "./spec/features/groups/members/list_members_spec.rb" +- "./spec/features/groups/members/manage_groups_spec.rb" +- "./spec/features/groups/members/manage_members_spec.rb" +- "./spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb" +- "./spec/features/groups/members/master_manages_access_requests_spec.rb" +- "./spec/features/groups/members/search_members_spec.rb" +- "./spec/features/groups/members/sort_members_spec.rb" +- "./spec/features/groups/members/tabs_spec.rb" +- "./spec/features/groups/merge_requests_spec.rb" +- "./spec/features/groups/milestones/gfm_autocomplete_spec.rb" +- "./spec/features/groups/milestone_spec.rb" +- "./spec/features/groups/milestones_sorting_spec.rb" +- "./spec/features/groups/packages_spec.rb" +- "./spec/features/groups/settings/group_badges_spec.rb" +- "./spec/features/groups/settings/packages_and_registries_spec.rb" +- "./spec/features/groups/settings/repository_spec.rb" +- "./spec/features/groups/settings/user_searches_in_settings_spec.rb" +- "./spec/features/groups/show_spec.rb" +- "./spec/features/groups_spec.rb" +- "./spec/features/groups/user_browse_projects_group_page_spec.rb" +- "./spec/features/groups/user_sees_users_dropdowns_in_issuables_list_spec.rb" +- "./spec/features/help_pages_spec.rb" +- "./spec/features/ide_spec.rb" +- "./spec/features/ide/user_commits_changes_spec.rb" +- "./spec/features/ide/user_opens_merge_request_spec.rb" +- "./spec/features/import/manifest_import_spec.rb" +- "./spec/features/incidents/incident_details_spec.rb" +- "./spec/features/incidents/incidents_list_spec.rb" +- "./spec/features/incidents/user_creates_new_incident_spec.rb" +- "./spec/features/incidents/user_filters_incidents_by_status_spec.rb" +- "./spec/features/incidents/user_searches_incidents_spec.rb" +- "./spec/features/incidents/user_views_incident_spec.rb" +- "./spec/features/issuables/issuable_list_spec.rb" +- "./spec/features/issuables/markdown_references/internal_references_spec.rb" +- "./spec/features/issuables/markdown_references/jira_spec.rb" +- "./spec/features/issuables/sorting_list_spec.rb" +- "./spec/features/issuables/user_sees_sidebar_spec.rb" +- "./spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb" +- "./spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb" +- "./spec/features/issues/csv_spec.rb" +- "./spec/features/issues/discussion_lock_spec.rb" +- "./spec/features/issues/filtered_search/dropdown_assignee_spec.rb" +- "./spec/features/issues/filtered_search/dropdown_author_spec.rb" +- "./spec/features/issues/filtered_search/dropdown_base_spec.rb" +- "./spec/features/issues/filtered_search/dropdown_emoji_spec.rb" +- "./spec/features/issues/filtered_search/dropdown_hint_spec.rb" +- "./spec/features/issues/filtered_search/dropdown_label_spec.rb" +- "./spec/features/issues/filtered_search/dropdown_milestone_spec.rb" +- "./spec/features/issues/filtered_search/dropdown_release_spec.rb" +- "./spec/features/issues/filtered_search/filter_issues_spec.rb" +- "./spec/features/issues/filtered_search/recent_searches_spec.rb" +- "./spec/features/issues/filtered_search/search_bar_spec.rb" +- "./spec/features/issues/filtered_search/visual_tokens_spec.rb" +- "./spec/features/issues/form_spec.rb" +- "./spec/features/issues/gfm_autocomplete_spec.rb" +- "./spec/features/issues/group_label_sidebar_spec.rb" +- "./spec/features/issues/incident_issue_spec.rb" +- "./spec/features/issues/issue_detail_spec.rb" +- "./spec/features/issues/issue_header_spec.rb" +- "./spec/features/issues/issue_sidebar_spec.rb" +- "./spec/features/issues/keyboard_shortcut_spec.rb" +- "./spec/features/issues/markdown_toolbar_spec.rb" +- "./spec/features/issues/move_spec.rb" +- "./spec/features/issues/note_polling_spec.rb" +- "./spec/features/issues/notes_on_issues_spec.rb" +- "./spec/features/issues/related_issues_spec.rb" +- "./spec/features/issues/resource_label_events_spec.rb" +- "./spec/features/issues/service_desk_spec.rb" +- "./spec/features/issues/spam_issues_spec.rb" +- "./spec/features/issues/todo_spec.rb" +- "./spec/features/issues/user_bulk_edits_issues_labels_spec.rb" +- "./spec/features/issues/user_bulk_edits_issues_spec.rb" +- "./spec/features/issues/user_comments_on_issue_spec.rb" +- "./spec/features/issues/user_creates_branch_and_merge_request_spec.rb" +- "./spec/features/issues/user_creates_confidential_merge_request_spec.rb" +- "./spec/features/issues/user_creates_issue_by_email_spec.rb" +- "./spec/features/issues/user_creates_issue_spec.rb" +- "./spec/features/issues/user_edits_issue_spec.rb" +- "./spec/features/issues/user_filters_issues_spec.rb" +- "./spec/features/issues/user_interacts_with_awards_spec.rb" +- "./spec/features/issues/user_invites_from_a_comment_spec.rb" +- "./spec/features/issues/user_resets_their_incoming_email_token_spec.rb" +- "./spec/features/issues/user_sees_empty_state_spec.rb" +- "./spec/features/issues/user_sees_live_update_spec.rb" +- "./spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb" +- "./spec/features/issues/user_sorts_issue_comments_spec.rb" +- "./spec/features/issues/user_sorts_issues_spec.rb" +- "./spec/features/issues/user_toggles_subscription_spec.rb" +- "./spec/features/issues/user_uses_quick_actions_spec.rb" +- "./spec/features/issues/user_views_issue_spec.rb" +- "./spec/features/issues/user_views_issues_spec.rb" +- "./spec/features/jira_connect/branches_spec.rb" +- "./spec/features/labels_hierarchy_spec.rb" +- "./spec/features/markdown/copy_as_gfm_spec.rb" +- "./spec/features/markdown/gitlab_flavored_markdown_spec.rb" +- "./spec/features/markdown/keyboard_shortcuts_spec.rb" +- "./spec/features/markdown/math_spec.rb" +- "./spec/features/markdown/mermaid_spec.rb" +- "./spec/features/markdown/metrics_spec.rb" +- "./spec/features/merge_request/batch_comments_spec.rb" +- "./spec/features/merge_request/close_reopen_report_toggle_spec.rb" +- "./spec/features/merge_request/maintainer_edits_fork_spec.rb" +- "./spec/features/merge_request/merge_request_discussion_lock_spec.rb" +- "./spec/features/merge_requests/filters_generic_behavior_spec.rb" +- "./spec/features/merge_requests/user_exports_as_csv_spec.rb" +- "./spec/features/merge_requests/user_filters_by_approvals_spec.rb" +- "./spec/features/merge_requests/user_filters_by_assignees_spec.rb" +- "./spec/features/merge_requests/user_filters_by_deployments_spec.rb" +- "./spec/features/merge_requests/user_filters_by_draft_spec.rb" +- "./spec/features/merge_requests/user_filters_by_labels_spec.rb" +- "./spec/features/merge_requests/user_filters_by_milestones_spec.rb" +- "./spec/features/merge_requests/user_filters_by_multiple_criteria_spec.rb" +- "./spec/features/merge_requests/user_filters_by_target_branch_spec.rb" +- "./spec/features/merge_requests/user_mass_updates_spec.rb" +- "./spec/features/merge_request/user_accepts_merge_request_spec.rb" +- "./spec/features/merge_request/user_allows_commits_from_memebers_who_can_merge_spec.rb" +- "./spec/features/merge_request/user_approves_spec.rb" +- "./spec/features/merge_request/user_assigns_themselves_spec.rb" +- "./spec/features/merge_request/user_awards_emoji_spec.rb" +- "./spec/features/merge_request/user_clicks_merge_request_tabs_spec.rb" +- "./spec/features/merge_request/user_comments_on_commit_spec.rb" +- "./spec/features/merge_request/user_comments_on_diff_spec.rb" +- "./spec/features/merge_request/user_comments_on_merge_request_spec.rb" +- "./spec/features/merge_request/user_creates_image_diff_notes_spec.rb" +- "./spec/features/merge_request/user_creates_merge_request_spec.rb" +- "./spec/features/merge_request/user_creates_mr_spec.rb" +- "./spec/features/merge_request/user_customizes_merge_commit_message_spec.rb" +- "./spec/features/merge_request/user_edits_assignees_sidebar_spec.rb" +- "./spec/features/merge_request/user_edits_merge_request_spec.rb" +- "./spec/features/merge_request/user_edits_mr_spec.rb" +- "./spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb" +- "./spec/features/merge_request/user_expands_diff_spec.rb" +- "./spec/features/merge_request/user_interacts_with_batched_mr_diffs_spec.rb" +- "./spec/features/merge_request/user_invites_from_a_comment_spec.rb" +- "./spec/features/merge_request/user_jumps_to_discussion_spec.rb" +- "./spec/features/merge_request/user_locks_discussion_spec.rb" +- "./spec/features/merge_request/user_manages_subscription_spec.rb" +- "./spec/features/merge_request/user_marks_merge_request_as_draft_spec.rb" +- "./spec/features/merge_request/user_merges_immediately_spec.rb" +- "./spec/features/merge_request/user_merges_merge_request_spec.rb" +- "./spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb" +- "./spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb" +- "./spec/features/merge_request/user_posts_diff_notes_spec.rb" +- "./spec/features/merge_request/user_posts_notes_spec.rb" +- "./spec/features/merge_request/user_rebases_merge_request_spec.rb" +- "./spec/features/merge_request/user_resolves_conflicts_spec.rb" +- "./spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb" +- "./spec/features/merge_request/user_resolves_outdated_diff_discussions_spec.rb" +- "./spec/features/merge_request/user_resolves_wip_mr_spec.rb" +- "./spec/features/merge_request/user_reverts_merge_request_spec.rb" +- "./spec/features/merge_request/user_reviews_image_spec.rb" +- "./spec/features/merge_request/user_scrolls_to_note_on_load_spec.rb" +- "./spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb" +- "./spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb" +- "./spec/features/merge_request/user_sees_cherry_pick_modal_spec.rb" +- "./spec/features/merge_request/user_sees_closing_issues_message_spec.rb" +- "./spec/features/merge_request/user_sees_deleted_target_branch_spec.rb" +- "./spec/features/merge_request/user_sees_deployment_widget_spec.rb" +- "./spec/features/merge_request/user_sees_diff_spec.rb" +- "./spec/features/merge_request/user_sees_discussions_spec.rb" +- "./spec/features/merge_request/user_sees_merge_button_depending_on_unresolved_discussions_spec.rb" +- "./spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb" +- "./spec/features/merge_request/user_sees_merge_widget_spec.rb" +- "./spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb" +- "./spec/features/merge_request/user_sees_mr_from_deleted_forked_project_spec.rb" +- "./spec/features/merge_request/user_sees_mr_with_deleted_source_branch_spec.rb" +- "./spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb" +- "./spec/features/merge_request/user_sees_pipelines_from_forked_project_spec.rb" +- "./spec/features/merge_request/user_sees_pipelines_spec.rb" +- "./spec/features/merge_request/user_sees_suggest_pipeline_spec.rb" +- "./spec/features/merge_request/user_sees_system_notes_spec.rb" +- "./spec/features/merge_request/user_sees_versions_spec.rb" +- "./spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb" +- "./spec/features/merge_request/user_squashes_merge_request_spec.rb" +- "./spec/features/merge_request/user_suggests_changes_on_diff_spec.rb" +- "./spec/features/merge_request/user_toggles_whitespace_changes_spec.rb" +- "./spec/features/merge_request/user_uses_quick_actions_spec.rb" +- "./spec/features/merge_request/user_views_auto_expanding_diff_spec.rb" +- "./spec/features/merge_request/user_views_diffs_commit_spec.rb" +- "./spec/features/merge_request/user_views_diffs_file_by_file_spec.rb" +- "./spec/features/merge_request/user_views_diffs_spec.rb" +- "./spec/features/merge_request/user_views_open_merge_request_spec.rb" +- "./spec/features/merge_request/user_views_user_status_on_merge_request_spec.rb" +- "./spec/features/milestone_spec.rb" +- "./spec/features/milestones/user_creates_milestone_spec.rb" +- "./spec/features/milestones/user_deletes_milestone_spec.rb" +- "./spec/features/milestones/user_edits_milestone_spec.rb" +- "./spec/features/milestones/user_views_milestone_spec.rb" +- "./spec/features/milestones/user_views_milestones_spec.rb" +- "./spec/features/nav/top_nav_responsive_spec.rb" +- "./spec/features/oauth_login_spec.rb" +- "./spec/features/participants_autocomplete_spec.rb" +- "./spec/features/populate_new_pipeline_vars_with_params_spec.rb" +- "./spec/features/profiles/account_spec.rb" +- "./spec/features/profiles/active_sessions_spec.rb" +- "./spec/features/profiles/keys_spec.rb" +- "./spec/features/profiles/oauth_applications_spec.rb" +- "./spec/features/profile_spec.rb" +- "./spec/features/profiles/personal_access_tokens_spec.rb" +- "./spec/features/profiles/user_changes_notified_of_own_activity_spec.rb" +- "./spec/features/profiles/user_edit_preferences_spec.rb" +- "./spec/features/profiles/user_edit_profile_spec.rb" +- "./spec/features/profiles/user_search_settings_spec.rb" +- "./spec/features/profiles/user_visits_notifications_tab_spec.rb" +- "./spec/features/profiles/user_visits_profile_preferences_page_spec.rb" +- "./spec/features/profiles/user_visits_profile_spec.rb" +- "./spec/features/project_group_variables_spec.rb" +- "./spec/features/projects/activity/user_sees_activity_spec.rb" +- "./spec/features/projects/activity/user_sees_design_activity_spec.rb" +- "./spec/features/projects/activity/user_sees_design_comment_spec.rb" +- "./spec/features/projects/activity/user_sees_private_activity_spec.rb" +- "./spec/features/projects/artifacts/file_spec.rb" +- "./spec/features/projects/artifacts/raw_spec.rb" +- "./spec/features/projects/artifacts/user_browses_artifacts_spec.rb" +- "./spec/features/projects/badges/list_spec.rb" +- "./spec/features/projects/badges/pipeline_badge_spec.rb" +- "./spec/features/projects/blobs/balsamiq_spec.rb" +- "./spec/features/projects/blobs/blob_line_permalink_updater_spec.rb" +- "./spec/features/projects/blobs/blob_show_spec.rb" +- "./spec/features/projects/blobs/edit_spec.rb" +- "./spec/features/projects/blobs/shortcuts_blob_spec.rb" +- "./spec/features/projects/blobs/user_creates_new_blob_in_new_project_spec.rb" +- "./spec/features/projects/blobs/user_follows_pipeline_suggest_nudge_spec.rb" +- "./spec/features/projects/blobs/user_views_pipeline_editor_button_spec.rb" +- "./spec/features/projects/branches/new_branch_ref_dropdown_spec.rb" +- "./spec/features/projects/branches_spec.rb" +- "./spec/features/projects/branches/user_creates_branch_spec.rb" +- "./spec/features/projects/branches/user_deletes_branch_spec.rb" +- "./spec/features/projects/branches/user_views_branches_spec.rb" +- "./spec/features/projects/ci/editor_spec.rb" +- "./spec/features/projects/clusters/eks_spec.rb" +- "./spec/features/projects/clusters/gcp_spec.rb" +- "./spec/features/projects/clusters_spec.rb" +- "./spec/features/projects/clusters/user_spec.rb" +- "./spec/features/projects/commit/builds_spec.rb" +- "./spec/features/projects/commit/cherry_pick_spec.rb" +- "./spec/features/projects/commit/comments/user_adds_comment_spec.rb" +- "./spec/features/projects/commit/comments/user_deletes_comments_spec.rb" +- "./spec/features/projects/commit/comments/user_edits_comments_spec.rb" +- "./spec/features/projects/commit/diff_notes_spec.rb" +- "./spec/features/projects/commit/mini_pipeline_graph_spec.rb" +- "./spec/features/projects/commits/user_browses_commits_spec.rb" +- "./spec/features/projects/commit/user_comments_on_commit_spec.rb" +- "./spec/features/projects/commit/user_reverts_commit_spec.rb" +- "./spec/features/projects/commit/user_views_user_status_on_commit_spec.rb" +- "./spec/features/projects/compare_spec.rb" +- "./spec/features/projects/container_registry_spec.rb" +- "./spec/features/projects/deploy_keys_spec.rb" +- "./spec/features/projects/diffs/diff_show_spec.rb" +- "./spec/features/projects/environments/environment_metrics_spec.rb" +- "./spec/features/projects/environments/environment_spec.rb" +- "./spec/features/projects/environments/environments_spec.rb" +- "./spec/features/projects/environments_pod_logs_spec.rb" +- "./spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb" +- "./spec/features/projects/feature_flags/user_deletes_feature_flag_spec.rb" +- "./spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb" +- "./spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb" +- "./spec/features/projects/feature_flag_user_lists/user_deletes_feature_flag_user_list_spec.rb" +- "./spec/features/projects/feature_flag_user_lists/user_edits_feature_flag_user_list_spec.rb" +- "./spec/features/projects/feature_flag_user_lists/user_sees_feature_flag_user_list_details_spec.rb" +- "./spec/features/projects/features_visibility_spec.rb" +- "./spec/features/projects/files/dockerfile_dropdown_spec.rb" +- "./spec/features/projects/files/edit_file_soft_wrap_spec.rb" +- "./spec/features/projects/files/files_sort_submodules_with_folders_spec.rb" +- "./spec/features/projects/files/find_file_keyboard_spec.rb" +- "./spec/features/projects/files/gitignore_dropdown_spec.rb" +- "./spec/features/projects/files/gitlab_ci_yml_dropdown_spec.rb" +- "./spec/features/projects/files/project_owner_creates_license_file_spec.rb" +- "./spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb" +- "./spec/features/projects/files/template_selector_menu_spec.rb" +- "./spec/features/projects/files/template_type_dropdown_spec.rb" +- "./spec/features/projects/files/undo_template_spec.rb" +- "./spec/features/projects/files/user_browses_a_tree_with_a_folder_containing_only_a_folder_spec.rb" +- "./spec/features/projects/files/user_browses_files_spec.rb" +- "./spec/features/projects/files/user_browses_lfs_files_spec.rb" +- "./spec/features/projects/files/user_creates_directory_spec.rb" +- "./spec/features/projects/files/user_creates_files_spec.rb" +- "./spec/features/projects/files/user_deletes_files_spec.rb" +- "./spec/features/projects/files/user_edits_files_spec.rb" +- "./spec/features/projects/files/user_find_file_spec.rb" +- "./spec/features/projects/files/user_reads_pipeline_status_spec.rb" +- "./spec/features/projects/files/user_replaces_files_spec.rb" +- "./spec/features/projects/files/user_uploads_files_spec.rb" +- "./spec/features/projects/fork_spec.rb" +- "./spec/features/projects/gfm_autocomplete_load_spec.rb" +- "./spec/features/projects/graph_spec.rb" +- "./spec/features/projects/import_export/export_file_spec.rb" +- "./spec/features/projects/import_export/import_file_spec.rb" +- "./spec/features/projects/infrastructure_registry_spec.rb" +- "./spec/features/projects/integrations/user_activates_asana_spec.rb" +- "./spec/features/projects/integrations/user_activates_assembla_spec.rb" +- "./spec/features/projects/integrations/user_activates_atlassian_bamboo_ci_spec.rb" +- "./spec/features/projects/integrations/user_activates_flowdock_spec.rb" +- "./spec/features/projects/integrations/user_activates_jira_spec.rb" +- "./spec/features/projects/integrations/user_activates_pivotaltracker_spec.rb" +- "./spec/features/projects/integrations/user_uses_inherited_settings_spec.rb" +- "./spec/features/projects/issuable_templates_spec.rb" +- "./spec/features/projects/issues/design_management/user_paginates_designs_spec.rb" +- "./spec/features/projects/issues/design_management/user_permissions_upload_spec.rb" +- "./spec/features/projects/issues/design_management/user_uploads_designs_spec.rb" +- "./spec/features/projects/issues/design_management/user_views_design_spec.rb" +- "./spec/features/projects/issues/design_management/user_views_designs_spec.rb" +- "./spec/features/projects/issues/design_management/user_views_designs_with_svg_xss_spec.rb" +- "./spec/features/projects/issues/email_participants_spec.rb" +- "./spec/features/projects/jobs/permissions_spec.rb" +- "./spec/features/projects/jobs_spec.rb" +- "./spec/features/projects/jobs/user_browses_job_spec.rb" +- "./spec/features/projects/jobs/user_browses_jobs_spec.rb" +- "./spec/features/projects/labels/issues_sorted_by_priority_spec.rb" +- "./spec/features/projects/labels/search_labels_spec.rb" +- "./spec/features/projects/labels/sort_labels_spec.rb" +- "./spec/features/projects/labels/subscription_spec.rb" +- "./spec/features/projects/labels/update_prioritization_spec.rb" +- "./spec/features/projects/labels/user_removes_labels_spec.rb" +- "./spec/features/projects/members/anonymous_user_sees_members_spec.rb" +- "./spec/features/projects/members/group_member_cannot_leave_group_project_spec.rb" +- "./spec/features/projects/members/group_members_spec.rb" +- "./spec/features/projects/members/group_requester_cannot_request_access_to_project_spec.rb" +- "./spec/features/projects/members/groups_with_access_list_spec.rb" +- "./spec/features/projects/members/invite_group_spec.rb" +- "./spec/features/projects/members/list_spec.rb" +- "./spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb" +- "./spec/features/projects/members/master_manages_access_requests_spec.rb" +- "./spec/features/projects/members/sorting_spec.rb" +- "./spec/features/projects/members/tabs_spec.rb" +- "./spec/features/projects/members/user_requests_access_spec.rb" +- "./spec/features/projects/merge_request_button_spec.rb" +- "./spec/features/projects/milestones/gfm_autocomplete_spec.rb" +- "./spec/features/projects/milestones/milestones_sorting_spec.rb" +- "./spec/features/projects/milestones/new_spec.rb" +- "./spec/features/projects/milestones/user_interacts_with_labels_spec.rb" +- "./spec/features/projects/network_graph_spec.rb" +- "./spec/features/projects/new_project_from_template_spec.rb" +- "./spec/features/projects/new_project_spec.rb" +- "./spec/features/projects/packages_spec.rb" +- "./spec/features/projects/pages/user_adds_domain_spec.rb" +- "./spec/features/projects/pages/user_edits_lets_encrypt_settings_spec.rb" +- "./spec/features/projects/pages/user_edits_settings_spec.rb" +- "./spec/features/projects/pipeline_schedules_spec.rb" +- "./spec/features/projects/pipelines/pipeline_spec.rb" +- "./spec/features/projects/pipelines/pipelines_spec.rb" +- "./spec/features/projects/product_analytics/graphs_spec.rb" +- "./spec/features/projects/releases/user_creates_release_spec.rb" +- "./spec/features/projects/releases/user_views_edit_release_spec.rb" +- "./spec/features/projects/releases/user_views_release_spec.rb" +- "./spec/features/projects/releases/user_views_releases_spec.rb" +- "./spec/features/projects/remote_mirror_spec.rb" +- "./spec/features/projects/serverless/functions_spec.rb" +- "./spec/features/projects/services/disable_triggers_spec.rb" +- "./spec/features/projects/services/prometheus_external_alerts_spec.rb" +- "./spec/features/projects/services/user_activates_emails_on_push_spec.rb" +- "./spec/features/projects/services/user_activates_irker_spec.rb" +- "./spec/features/projects/services/user_activates_issue_tracker_spec.rb" +- "./spec/features/projects/services/user_activates_jetbrains_teamcity_ci_spec.rb" +- "./spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb" +- "./spec/features/projects/services/user_activates_packagist_spec.rb" +- "./spec/features/projects/services/user_activates_prometheus_spec.rb" +- "./spec/features/projects/services/user_activates_pushover_spec.rb" +- "./spec/features/projects/services/user_activates_slack_notifications_spec.rb" +- "./spec/features/projects/services/user_activates_slack_slash_command_spec.rb" +- "./spec/features/projects/services/user_views_services_spec.rb" +- "./spec/features/projects/settings/access_tokens_spec.rb" +- "./spec/features/projects/settings/lfs_settings_spec.rb" +- "./spec/features/projects/settings/monitor_settings_spec.rb" +- "./spec/features/projects/settings/packages_settings_spec.rb" +- "./spec/features/projects/settings/project_badges_spec.rb" +- "./spec/features/projects/settings/project_settings_spec.rb" +- "./spec/features/projects/settings/registry_settings_spec.rb" +- "./spec/features/projects/settings/repository_settings_spec.rb" +- "./spec/features/projects/settings/service_desk_setting_spec.rb" +- "./spec/features/projects/settings/user_changes_default_branch_spec.rb" +- "./spec/features/projects/settings/user_interacts_with_deploy_keys_spec.rb" +- "./spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb" +- "./spec/features/projects/settings/user_manages_project_members_spec.rb" +- "./spec/features/projects/settings/user_searches_in_settings_spec.rb" +- "./spec/features/projects/settings/user_sees_revoke_deploy_token_modal_spec.rb" +- "./spec/features/projects/settings/user_tags_project_spec.rb" +- "./spec/features/projects/settings/user_transfers_a_project_spec.rb" +- "./spec/features/projects/settings/visibility_settings_spec.rb" +- "./spec/features/projects/settings/webhooks_settings_spec.rb" +- "./spec/features/projects/show/schema_markup_spec.rb" +- "./spec/features/projects/show/user_interacts_with_auto_devops_banner_spec.rb" +- "./spec/features/projects/show/user_interacts_with_stars_spec.rb" +- "./spec/features/projects/show/user_manages_notifications_spec.rb" +- "./spec/features/projects/show/user_sees_collaboration_links_spec.rb" +- "./spec/features/projects/show/user_sees_last_commit_ci_status_spec.rb" +- "./spec/features/projects/show/user_sees_readme_spec.rb" +- "./spec/features/projects/show/user_uploads_files_spec.rb" +- "./spec/features/projects/snippets/create_snippet_spec.rb" +- "./spec/features/projects/snippets/show_spec.rb" +- "./spec/features/projects/snippets/user_comments_on_snippet_spec.rb" +- "./spec/features/projects/snippets/user_deletes_snippet_spec.rb" +- "./spec/features/projects/snippets/user_updates_snippet_spec.rb" +- "./spec/features/projects_spec.rb" +- "./spec/features/projects/sub_group_issuables_spec.rb" +- "./spec/features/projects/tags/user_edits_tags_spec.rb" +- "./spec/features/projects/terraform_spec.rb" +- "./spec/features/projects/tree/create_directory_spec.rb" +- "./spec/features/projects/tree/create_file_spec.rb" +- "./spec/features/projects/tree/tree_show_spec.rb" +- "./spec/features/projects/tree/upload_file_spec.rb" +- "./spec/features/projects/user_changes_project_visibility_spec.rb" +- "./spec/features/projects/user_creates_project_spec.rb" +- "./spec/features/projects/user_sees_sidebar_spec.rb" +- "./spec/features/projects/user_sees_user_popover_spec.rb" +- "./spec/features/projects/user_uses_shortcuts_spec.rb" +- "./spec/features/projects/user_views_empty_project_spec.rb" +- "./spec/features/projects/view_on_env_spec.rb" +- "./spec/features/projects/wikis_spec.rb" +- "./spec/features/projects/wiki/user_views_wiki_empty_spec.rb" +- "./spec/features/project_variables_spec.rb" +- "./spec/features/promotion_spec.rb" +- "./spec/features/protected_branches_spec.rb" +- "./spec/features/protected_tags_spec.rb" +- "./spec/features/reportable_note/commit_spec.rb" +- "./spec/features/reportable_note/issue_spec.rb" +- "./spec/features/reportable_note/merge_request_spec.rb" +- "./spec/features/reportable_note/snippets_spec.rb" +- "./spec/features/runners_spec.rb" +- "./spec/features/search/user_searches_for_code_spec.rb" +- "./spec/features/search/user_searches_for_commits_spec.rb" +- "./spec/features/search/user_searches_for_issues_spec.rb" +- "./spec/features/search/user_searches_for_merge_requests_spec.rb" +- "./spec/features/search/user_searches_for_milestones_spec.rb" +- "./spec/features/search/user_searches_for_projects_spec.rb" +- "./spec/features/search/user_searches_for_users_spec.rb" +- "./spec/features/search/user_searches_for_wiki_pages_spec.rb" +- "./spec/features/search/user_uses_header_search_field_spec.rb" +- "./spec/features/search/user_uses_search_filters_spec.rb" +- "./spec/features/signed_commits_spec.rb" +- "./spec/features/snippets/embedded_snippet_spec.rb" +- "./spec/features/snippets/internal_snippet_spec.rb" +- "./spec/features/snippets/notes_on_personal_snippets_spec.rb" +- "./spec/features/snippets/private_snippets_spec.rb" +- "./spec/features/snippets/public_snippets_spec.rb" +- "./spec/features/snippets/show_spec.rb" +- "./spec/features/snippets/user_creates_snippet_spec.rb" +- "./spec/features/snippets/user_deletes_snippet_spec.rb" +- "./spec/features/snippets/user_edits_snippet_spec.rb" +- "./spec/features/tags/developer_creates_tag_spec.rb" +- "./spec/features/tags/developer_deletes_tag_spec.rb" +- "./spec/features/tags/developer_updates_tag_spec.rb" +- "./spec/features/task_lists_spec.rb" +- "./spec/features/triggers_spec.rb" +- "./spec/features/u2f_spec.rb" +- "./spec/features/uploads/user_uploads_avatar_to_profile_spec.rb" +- "./spec/features/uploads/user_uploads_file_to_note_spec.rb" +- "./spec/features/user_can_display_performance_bar_spec.rb" +- "./spec/features/user_opens_link_to_comment_spec.rb" +- "./spec/features/user_sees_revert_modal_spec.rb" +- "./spec/features/users/login_spec.rb" +- "./spec/features/users/logout_spec.rb" +- "./spec/features/users/overview_spec.rb" +- "./spec/features/users/signup_spec.rb" +- "./spec/features/users/snippets_spec.rb" +- "./spec/features/users/terms_spec.rb" +- "./spec/features/users/user_browses_projects_on_user_page_spec.rb" +- "./spec/features/webauthn_spec.rb" +- "./spec/features/whats_new_spec.rb" +- "./spec/finders/ci/pipeline_schedules_finder_spec.rb" +- "./spec/finders/ci/pipelines_finder_spec.rb" +- "./spec/finders/ci/pipelines_for_merge_request_finder_spec.rb" +- "./spec/finders/projects_finder_spec.rb" +- "./spec/finders/releases/evidence_pipeline_finder_spec.rb" +- "./spec/frontend/fixtures/analytics.rb" +- "./spec/frontend/fixtures/jobs.rb" +- "./spec/frontend/fixtures/pipeline_schedules.rb" +- "./spec/frontend/fixtures/pipelines.rb" +- "./spec/graphql/mutations/design_management/upload_spec.rb" +- "./spec/graphql/mutations/merge_requests/accept_spec.rb" +- "./spec/graphql/resolvers/ci/test_report_summary_resolver_spec.rb" +- "./spec/helpers/issuables_helper_spec.rb" +- "./spec/initializers/active_record_locking_spec.rb" +- "./spec/initializers/database_config_spec.rb" +- "./spec/lib/gitlab/auth_spec.rb" +- "./spec/lib/gitlab/ci/badge/pipeline/status_spec.rb" +- "./spec/lib/gitlab/ci/build/policy/changes_spec.rb" +- "./spec/lib/gitlab/ci/charts_spec.rb" +- "./spec/lib/gitlab/ci/config_spec.rb" +- "./spec/lib/gitlab/ci/pipeline/chain/create_spec.rb" +- "./spec/lib/gitlab/ci/pipeline/chain/seed_block_spec.rb" +- "./spec/lib/gitlab/ci/pipeline/seed/build_spec.rb" +- "./spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb" +- "./spec/lib/gitlab/ci/status/stage/common_spec.rb" +- "./spec/lib/gitlab/ci/status/stage/factory_spec.rb" +- "./spec/lib/gitlab/ci/status/stage/play_manual_spec.rb" +- "./spec/lib/gitlab/ci/templates/5_minute_production_app_ci_yaml_spec.rb" +- "./spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb" +- "./spec/lib/gitlab/ci/templates/AWS/deploy_ecs_gitlab_ci_yaml_spec.rb" +- "./spec/lib/gitlab/ci/templates/Jobs/deploy_gitlab_ci_yaml_spec.rb" +- "./spec/lib/gitlab/ci/templates/managed_cluster_applications_gitlab_ci_yaml_spec.rb" +- "./spec/lib/gitlab/database/bulk_update_spec.rb" +- "./spec/lib/gitlab/database/connection_spec.rb" +- "./spec/lib/gitlab/database/load_balancing/host_spec.rb" +- "./spec/lib/gitlab/database/load_balancing_spec.rb" +- "./spec/lib/gitlab/database/postgresql_adapter/force_disconnectable_mixin_spec.rb" +- "./spec/lib/gitlab/database/postgresql_adapter/type_map_cache_spec.rb" +- "./spec/lib/gitlab/database/schema_migrations/context_spec.rb" +- "./spec/lib/gitlab/database/with_lock_retries_outside_transaction_spec.rb" +- "./spec/lib/gitlab/database/with_lock_retries_spec.rb" +- "./spec/lib/gitlab/data_builder/pipeline_spec.rb" +- "./spec/lib/gitlab/email/handler/create_issue_handler_spec.rb" +- "./spec/lib/gitlab/email/handler/create_merge_request_handler_spec.rb" +- "./spec/lib/gitlab/email/handler/create_note_handler_spec.rb" +- "./spec/lib/gitlab/email/handler/create_note_on_issuable_handler_spec.rb" +- "./spec/lib/gitlab/email/handler/unsubscribe_handler_spec.rb" +- "./spec/lib/gitlab/usage_data_spec.rb" +- "./spec/lib/peek/views/active_record_spec.rb" +- "./spec/mailers/emails/pipelines_spec.rb" +- "./spec/migrations/20210205174154_remove_bad_dependency_proxy_manifests_spec.rb" +- "./spec/migrations/20210722150102_operations_feature_flags_correct_flexible_rollout_values_spec.rb" +- "./spec/migrations/backfill_escalation_policies_for_oncall_schedules_spec.rb" +- "./spec/migrations/insert_ci_daily_pipeline_schedule_triggers_plan_limits_spec.rb" +- "./spec/migrations/remove_duplicate_dast_site_tokens_with_same_token_spec.rb" +- "./spec/models/ci/bridge_spec.rb" +- "./spec/models/ci/build_need_spec.rb" +- "./spec/models/ci/build_spec.rb" +- "./spec/models/ci/build_trace_chunk_spec.rb" +- "./spec/models/ci/commit_with_pipeline_spec.rb" +- "./spec/models/ci/group_spec.rb" +- "./spec/models/ci/group_variable_spec.rb" +- "./spec/models/ci/instance_variable_spec.rb" +- "./spec/models/ci/job_artifact_spec.rb" +- "./spec/models/ci/job_variable_spec.rb" +- "./spec/models/ci/legacy_stage_spec.rb" +- "./spec/models/ci/pipeline_schedule_spec.rb" +- "./spec/models/ci/pipeline_spec.rb" +- "./spec/models/ci/runner_namespace_spec.rb" +- "./spec/models/ci/runner_project_spec.rb" +- "./spec/models/ci/runner_spec.rb" +- "./spec/models/ci/running_build_spec.rb" +- "./spec/models/ci/stage_spec.rb" +- "./spec/models/ci/variable_spec.rb" +- "./spec/models/clusters/applications/jupyter_spec.rb" +- "./spec/models/clusters/applications/runner_spec.rb" +- "./spec/models/commit_collection_spec.rb" +- "./spec/models/commit_status_spec.rb" +- "./spec/models/concerns/batch_destroy_dependent_associations_spec.rb" +- "./spec/models/concerns/bulk_insertable_associations_spec.rb" +- "./spec/models/concerns/cron_schedulable_spec.rb" +- "./spec/models/concerns/has_environment_scope_spec.rb" +- "./spec/models/concerns/schedulable_spec.rb" +- "./spec/models/concerns/token_authenticatable_spec.rb" +- "./spec/models/design_management/version_spec.rb" +- "./spec/models/environment_status_spec.rb" +- "./spec/models/hooks/system_hook_spec.rb" +- "./spec/models/issue_spec.rb" +- "./spec/models/members/project_member_spec.rb" +- "./spec/models/merge_request_spec.rb" +- "./spec/models/plan_spec.rb" +- "./spec/models/project_feature_usage_spec.rb" +- "./spec/models/project_spec.rb" +- "./spec/models/spam_log_spec.rb" +- "./spec/models/user_spec.rb" +- "./spec/models/user_status_spec.rb" +- "./spec/policies/ci/build_policy_spec.rb" +- "./spec/policies/ci/pipeline_policy_spec.rb" +- "./spec/presenters/ci/stage_presenter_spec.rb" +- "./spec/requests/api/admin/ci/variables_spec.rb" +- "./spec/requests/api/admin/plan_limits_spec.rb" +- "./spec/requests/api/ci/jobs_spec.rb" +- "./spec/requests/api/ci/pipeline_schedules_spec.rb" +- "./spec/requests/api/ci/pipelines_spec.rb" +- "./spec/requests/api/ci/runner/runners_post_spec.rb" +- "./spec/requests/api/ci/runners_spec.rb" +- "./spec/requests/api/commits_spec.rb" +- "./spec/requests/api/commit_statuses_spec.rb" +- "./spec/requests/api/graphql/ci/runner_spec.rb" +- "./spec/requests/api/graphql/mutations/ci/pipeline_destroy_spec.rb" +- "./spec/requests/api/graphql/project/issues_spec.rb" +- "./spec/requests/api/graphql/project/merge_request_spec.rb" +- "./spec/requests/api/graphql/project_query_spec.rb" +- "./spec/requests/api/issues/issues_spec.rb" +- "./spec/requests/api/merge_requests_spec.rb" +- "./spec/requests/api/projects_spec.rb" +- "./spec/requests/api/resource_access_tokens_spec.rb" +- "./spec/requests/api/users_spec.rb" +- "./spec/requests/lfs_http_spec.rb" +- "./spec/requests/projects/cycle_analytics_events_spec.rb" +- "./spec/serializers/ci/downloadable_artifact_entity_spec.rb" +- "./spec/serializers/ci/downloadable_artifact_serializer_spec.rb" +- "./spec/serializers/ci/pipeline_entity_spec.rb" +- "./spec/serializers/merge_request_poll_cached_widget_entity_spec.rb" +- "./spec/serializers/merge_request_poll_widget_entity_spec.rb" +- "./spec/serializers/merge_request_widget_entity_spec.rb" +- "./spec/serializers/pipeline_details_entity_spec.rb" +- "./spec/serializers/pipeline_serializer_spec.rb" +- "./spec/serializers/stage_entity_spec.rb" +- "./spec/serializers/stage_serializer_spec.rb" +- "./spec/serializers/test_report_entity_spec.rb" +- "./spec/serializers/test_report_summary_entity_spec.rb" +- "./spec/serializers/test_suite_entity_spec.rb" +- "./spec/serializers/test_suite_summary_entity_spec.rb" +- "./spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb" +- "./spec/services/ci/compare_accessibility_reports_service_spec.rb" +- "./spec/services/ci/compare_codequality_reports_service_spec.rb" +- "./spec/services/ci/compare_reports_base_service_spec.rb" +- "./spec/services/ci/compare_test_reports_service_spec.rb" +- "./spec/services/ci/create_pipeline_service/environment_spec.rb" +- "./spec/services/ci/create_pipeline_service_spec.rb" +- "./spec/services/ci/destroy_pipeline_service_spec.rb" +- "./spec/services/ci/disable_user_pipeline_schedules_service_spec.rb" +- "./spec/services/ci/ensure_stage_service_spec.rb" +- "./spec/services/ci/expire_pipeline_cache_service_spec.rb" +- "./spec/services/ci/generate_codequality_mr_diff_report_service_spec.rb" +- "./spec/services/ci/generate_coverage_reports_service_spec.rb" +- "./spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb" +- "./spec/services/ci/job_artifacts/destroy_associations_service_spec.rb" +- "./spec/services/ci/job_artifacts/destroy_batch_service_spec.rb" +- "./spec/services/ci/pipeline_artifacts/coverage_report_service_spec.rb" +- "./spec/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service_spec.rb" +- "./spec/services/ci/pipeline_bridge_status_service_spec.rb" +- "./spec/services/ci/pipeline_processing/shared_processing_service.rb" +- "./spec/services/ci/pipelines/add_job_service_spec.rb" +- "./spec/services/ci/pipeline_schedule_service_spec.rb" +- "./spec/services/ci/pipeline_trigger_service_spec.rb" +- "./spec/services/ci/register_job_service_spec.rb" +- "./spec/services/ci/retry_build_service_spec.rb" +- "./spec/services/ci/test_failure_history_service_spec.rb" +- "./spec/services/ci/update_instance_variables_service_spec.rb" +- "./spec/services/deployments/update_environment_service_spec.rb" +- "./spec/services/design_management/save_designs_service_spec.rb" +- "./spec/services/environments/stop_service_spec.rb" +- "./spec/services/groups/transfer_service_spec.rb" +- "./spec/services/integrations/test/project_service_spec.rb" +- "./spec/services/issuable/destroy_service_spec.rb" +- "./spec/services/issue_links/list_service_spec.rb" +- "./spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb" +- "./spec/services/merge_requests/mergeability_check_service_spec.rb" +- "./spec/services/merge_requests/post_merge_service_spec.rb" +- "./spec/services/merge_requests/refresh_service_spec.rb" +- "./spec/services/pages/migrate_from_legacy_storage_service_spec.rb" +- "./spec/services/projects/destroy_service_spec.rb" +- "./spec/services/projects/transfer_service_spec.rb" +- "./spec/services/projects/update_service_spec.rb" +- "./spec/services/releases/create_service_spec.rb" +- "./spec/services/resource_access_tokens/revoke_service_spec.rb" +- "./spec/services/todo_service_spec.rb" +- "./spec/services/users/activity_service_spec.rb" +- "./spec/services/users/destroy_service_spec.rb" +- "./spec/services/users/reject_service_spec.rb" +- "./spec/support/shared_contexts/email_shared_context.rb" +- "./spec/support/shared_examples/controllers/access_tokens_controller_shared_examples.rb" +- "./spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb" +- "./spec/support/shared_examples/integrations/test_examples.rb" +- "./spec/support/shared_examples/models/atomic_internal_id_shared_examples.rb" +- "./spec/support/shared_examples/models/cluster_application_status_shared_examples.rb" +- "./spec/support/shared_examples/models/cluster_application_version_shared_examples.rb" +- "./spec/support/shared_examples/models/concerns/cron_schedulable_shared_examples.rb" +- "./spec/support/shared_examples/models/concerns/limitable_shared_examples.rb" +- "./spec/support/shared_examples/models/update_highest_role_shared_examples.rb" +- "./spec/support/shared_examples/models/update_project_statistics_shared_examples.rb" +- "./spec/support/shared_examples/models/with_uploads_shared_examples.rb" +- "./spec/support/shared_examples/requests/api/status_shared_examples.rb" +- "./spec/support/shared_examples/requests/lfs_http_shared_examples.rb" +- "./spec/support/shared_examples/services/destroy_label_links_shared_examples.rb" +- "./spec/support/shared_examples/services/issuable/destroy_service_shared_examples.rb" +- "./spec/support/shared_examples/services/notification_service_shared_examples.rb" +- "./spec/support/shared_examples/services/wiki_pages/create_service_shared_examples.rb" +- "./spec/support/shared_examples/services/wiki_pages/destroy_service_shared_examples.rb" +- "./spec/support/shared_examples/services/wiki_pages/update_service_shared_examples.rb" +- "./spec/support/shared_examples/workers/idempotency_shared_examples.rb" +- "./spec/views/projects/artifacts/_artifact.html.haml_spec.rb" +- "./spec/views/projects/commits/_commit.html.haml_spec.rb" +- "./spec/views/projects/jobs/_build.html.haml_spec.rb" +- "./spec/views/projects/jobs/_generic_commit_status.html.haml_spec.rb" +- "./spec/views/projects/merge_requests/creations/_new_submit.html.haml_spec.rb" +- "./spec/views/projects/pipeline_schedules/_pipeline_schedule.html.haml_spec.rb" +- "./spec/views/shared/runners/_runner_details.html.haml_spec.rb" +- "./spec/workers/authorized_project_update/user_refresh_from_replica_worker_spec.rb" +- "./spec/workers/ci/pipeline_artifacts/create_quality_report_worker_spec.rb" +- "./spec/workers/container_expiration_policy_worker_spec.rb" +- "./spec/workers/merge_requests/create_pipeline_worker_spec.rb" +- "./spec/workers/pipeline_metrics_worker_spec.rb" +- "./spec/workers/pipeline_schedule_worker_spec.rb" +- "./spec/workers/releases/create_evidence_worker_spec.rb" +- "./spec/workers/remove_expired_members_worker_spec.rb" +- "./spec/workers/repository_cleanup_worker_spec.rb" +- "./spec/workers/stage_update_worker_spec.rb" +- "./spec/workers/stuck_merge_jobs_worker_spec.rb" +- "./ee/spec/requests/api/graphql/project/pipelines/dast_profile_spec.rb" +- "./spec/services/projects/overwrite_project_service_spec.rb" diff --git a/spec/support/database/cross-join-allowlist.yml b/spec/support/database/cross-join-allowlist.yml index 45e95cf3262..c209d275fc8 100644 --- a/spec/support/database/cross-join-allowlist.yml +++ b/spec/support/database/cross-join-allowlist.yml @@ -1,197 +1,58 @@ -- "./ee/spec/controllers/operations_controller_spec.rb" -- "./ee/spec/controllers/projects/issues_controller_spec.rb" -- "./ee/spec/controllers/projects/security/vulnerabilities_controller_spec.rb" - "./ee/spec/features/ci/ci_minutes_spec.rb" -- "./ee/spec/features/merge_request/user_merges_immediately_spec.rb" -- "./ee/spec/features/merge_request/user_sees_merge_widget_spec.rb" - "./ee/spec/features/merge_trains/two_merge_requests_on_train_spec.rb" - "./ee/spec/features/merge_trains/user_adds_merge_request_to_merge_train_spec.rb" -- "./ee/spec/features/merge_trains/user_adds_to_merge_train_when_pipeline_succeeds_spec.rb" -- "./ee/spec/features/projects/pipelines/pipeline_spec.rb" -- "./ee/spec/features/projects/settings/auto_rollback_spec.rb" -- "./ee/spec/features/projects/settings/pipeline_subscriptions_spec.rb" -- "./ee/spec/features/projects/settings/protected_environments_spec.rb" - "./ee/spec/finders/ee/namespaces/projects_finder_spec.rb" -- "./ee/spec/finders/group_projects_finder_spec.rb" -- "./ee/spec/finders/security/findings_finder_spec.rb" - "./ee/spec/graphql/ee/resolvers/namespace_projects_resolver_spec.rb" -- "./ee/spec/lib/analytics/devops_adoption/snapshot_calculator_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/migrate_approver_to_approval_rules_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/migrate_security_scans_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/populate_latest_pipeline_ids_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/populate_resolved_on_default_branch_column_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/populate_uuids_for_security_findings_spec.rb" -- "./ee/spec/lib/ee/gitlab/background_migration/populate_vulnerability_feedback_pipeline_id_spec.rb" -- "./ee/spec/lib/ee/gitlab/usage_data_spec.rb" -- "./ee/spec/migrations/schedule_populate_resolved_on_default_branch_column_spec.rb" -- "./ee/spec/models/ci/build_spec.rb" - "./ee/spec/models/ci/minutes/project_monthly_usage_spec.rb" -- "./ee/spec/models/ci/pipeline_spec.rb" -- "./ee/spec/models/ee/vulnerability_spec.rb" -- "./ee/spec/models/merge_request_spec.rb" - "./ee/spec/models/project_spec.rb" - "./ee/spec/models/security/finding_spec.rb" - "./ee/spec/models/security/scan_spec.rb" -- "./ee/spec/presenters/ci/pipeline_presenter_spec.rb" - "./ee/spec/requests/api/ci/minutes_spec.rb" - "./ee/spec/requests/api/graphql/ci/minutes/usage_spec.rb" -- "./ee/spec/requests/api/graphql/mutations/environments/canary_ingress/update_spec.rb" -- "./ee/spec/requests/api/graphql/mutations/vulnerabilities/create_external_issue_link_spec.rb" -- "./ee/spec/requests/api/graphql/project/pipeline/security_report_summary_spec.rb" -- "./ee/spec/requests/api/graphql/vulnerabilities/location_spec.rb" -- "./ee/spec/requests/api/groups_spec.rb" - "./ee/spec/requests/api/namespaces_spec.rb" -- "./ee/spec/requests/api/vulnerability_findings_spec.rb" -- "./ee/spec/serializers/dashboard_environment_entity_spec.rb" -- "./ee/spec/serializers/dashboard_environments_serializer_spec.rb" -- "./ee/spec/services/auto_merge/add_to_merge_train_when_pipeline_succeeds_service_spec.rb" -- "./ee/spec/services/ci/create_pipeline_service/runnable_builds_spec.rb" - "./ee/spec/services/ci/minutes/additional_packs/change_namespace_service_spec.rb" - "./ee/spec/services/ci/minutes/additional_packs/create_service_spec.rb" - "./ee/spec/services/ci/minutes/refresh_cached_data_service_spec.rb" -- "./ee/spec/services/ci/process_pipeline_service_spec.rb" -- "./ee/spec/services/ci/trigger_downstream_subscription_service_spec.rb" -- "./ee/spec/services/clear_namespace_shared_runners_minutes_service_spec.rb" -- "./ee/spec/services/deployments/auto_rollback_service_spec.rb" -- "./ee/spec/services/ee/ci/job_artifacts/destroy_all_expired_service_spec.rb" -- "./ee/spec/services/ee/ci/job_artifacts/destroy_batch_service_spec.rb" -- "./ee/spec/services/ee/issues/build_from_vulnerability_service_spec.rb" -- "./ee/spec/services/ee/merge_requests/create_pipeline_service_spec.rb" -- "./ee/spec/services/ee/merge_requests/refresh_service_spec.rb" -- "./ee/spec/services/security/report_summary_service_spec.rb" -- "./ee/spec/services/security/vulnerability_counting_service_spec.rb" -- "./ee/spec/workers/scan_security_report_secrets_worker_spec.rb" -- "./ee/spec/workers/security/store_scans_worker_spec.rb" - "./spec/controllers/admin/runners_controller_spec.rb" -- "./spec/controllers/groups/runners_controller_spec.rb" - "./spec/controllers/groups/settings/ci_cd_controller_spec.rb" -- "./spec/controllers/projects/logs_controller_spec.rb" -- "./spec/controllers/projects/merge_requests_controller_spec.rb" -- "./spec/controllers/projects/runners_controller_spec.rb" -- "./spec/controllers/projects/serverless/functions_controller_spec.rb" - "./spec/controllers/projects/settings/ci_cd_controller_spec.rb" - "./spec/features/admin/admin_runners_spec.rb" -- "./spec/features/groups/settings/ci_cd_spec.rb" - "./spec/features/ide/user_opens_merge_request_spec.rb" -- "./spec/features/merge_request/user_merges_immediately_spec.rb" -- "./spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb" -- "./spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb" -- "./spec/features/merge_request/user_resolves_wip_mr_spec.rb" -- "./spec/features/merge_request/user_sees_deployment_widget_spec.rb" - "./spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb" -- "./spec/features/merge_request/user_sees_merge_widget_spec.rb" -- "./spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb" -- "./spec/features/merge_request/user_sees_pipelines_from_forked_project_spec.rb" -- "./spec/features/merge_request/user_sees_pipelines_spec.rb" -- "./spec/features/project_group_variables_spec.rb" -- "./spec/features/project_variables_spec.rb" -- "./spec/features/projects/badges/list_spec.rb" -- "./spec/features/projects/environments_pod_logs_spec.rb" - "./spec/features/projects/infrastructure_registry_spec.rb" -- "./spec/features/projects/jobs_spec.rb" -- "./spec/features/projects/package_files_spec.rb" -- "./spec/features/projects/pipelines/pipeline_spec.rb" -- "./spec/features/projects/pipelines/pipelines_spec.rb" -- "./spec/features/projects/serverless/functions_spec.rb" -- "./spec/features/projects/settings/pipelines_settings_spec.rb" -- "./spec/features/runners_spec.rb" -- "./spec/features/security/project/internal_access_spec.rb" -- "./spec/features/security/project/private_access_spec.rb" -- "./spec/features/security/project/public_access_spec.rb" -- "./spec/features/triggers_spec.rb" -- "./spec/finders/ci/pipelines_finder_spec.rb" - "./spec/finders/ci/pipelines_for_merge_request_finder_spec.rb" - "./spec/finders/ci/runners_finder_spec.rb" -- "./spec/finders/clusters/knative_services_finder_spec.rb" -- "./spec/finders/projects/serverless/functions_finder_spec.rb" - "./spec/frontend/fixtures/runner.rb" -- "./spec/graphql/mutations/ci/runner/delete_spec.rb" - "./spec/graphql/resolvers/ci/group_runners_resolver_spec.rb" -- "./spec/graphql/resolvers/ci/job_token_scope_resolver_spec.rb" -- "./spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb" -- "./spec/graphql/types/ci/job_token_scope_type_spec.rb" -- "./spec/helpers/packages_helper_spec.rb" - "./spec/lib/api/entities/package_spec.rb" -- "./spec/lib/gitlab/background_migration/migrate_legacy_artifacts_spec.rb" -- "./spec/lib/gitlab/prometheus/query_variables_spec.rb" -- "./spec/mailers/emails/pipelines_spec.rb" +- "./spec/lib/gitlab/background_migration/copy_ci_builds_columns_to_security_scans_spec.rb" +- "./spec/lib/gitlab/background_migration/migrate_pages_metadata_spec.rb" - "./spec/migrations/20210907211557_finalize_ci_builds_bigint_conversion_spec.rb" -- "./spec/migrations/cleanup_legacy_artifact_migration_spec.rb" -- "./spec/migrations/migrate_protected_attribute_to_pending_builds_spec.rb" -- "./spec/migrations/re_schedule_latest_pipeline_id_population_with_all_security_related_artifact_types_spec.rb" -- "./spec/migrations/schedule_migrate_security_scans_spec.rb" -- "./spec/models/ci/build_spec.rb" -- "./spec/models/ci/job_artifact_spec.rb" -- "./spec/models/ci/job_token/scope_spec.rb" +- "./spec/migrations/associate_existing_dast_builds_with_variables_spec.rb" +- "./spec/migrations/schedule_copy_ci_builds_columns_to_security_scans2_spec.rb" +- "./spec/migrations/schedule_pages_metadata_migration_spec.rb" - "./spec/models/ci/pipeline_spec.rb" - "./spec/models/ci/runner_spec.rb" -- "./spec/models/clusters/applications/runner_spec.rb" -- "./spec/models/deployment_spec.rb" -- "./spec/models/environment_spec.rb" - "./spec/models/merge_request_spec.rb" - "./spec/models/project_spec.rb" - "./spec/models/user_spec.rb" -- "./spec/presenters/ci/build_runner_presenter_spec.rb" -- "./spec/presenters/ci/pipeline_presenter_spec.rb" - "./spec/presenters/packages/detail/package_presenter_spec.rb" -- "./spec/requests/api/ci/pipelines_spec.rb" -- "./spec/requests/api/ci/runner/jobs_request_post_spec.rb" - "./spec/requests/api/ci/runner/runners_post_spec.rb" - "./spec/requests/api/ci/runners_spec.rb" -- "./spec/requests/api/commit_statuses_spec.rb" +- "./spec/requests/api/graphql/ci/runner_spec.rb" - "./spec/requests/api/graphql/group_query_spec.rb" -- "./spec/requests/api/graphql/merge_request/merge_request_spec.rb" -- "./spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb" -- "./spec/requests/api/graphql/mutations/ci/job_token_scope/remove_project_spec.rb" -- "./spec/requests/api/graphql/mutations/environments/canary_ingress/update_spec.rb" -- "./spec/requests/api/graphql/mutations/merge_requests/create_spec.rb" - "./spec/requests/api/graphql/packages/composer_spec.rb" - "./spec/requests/api/graphql/packages/conan_spec.rb" - "./spec/requests/api/graphql/packages/maven_spec.rb" - "./spec/requests/api/graphql/packages/nuget_spec.rb" - "./spec/requests/api/graphql/packages/package_spec.rb" - "./spec/requests/api/graphql/packages/pypi_spec.rb" -- "./spec/requests/api/graphql/project/merge_request/pipelines_spec.rb" -- "./spec/requests/api/graphql/project/merge_request_spec.rb" -- "./spec/requests/api/graphql/project/merge_requests_spec.rb" -- "./spec/requests/api/graphql/project/pipeline_spec.rb" -- "./spec/requests/api/merge_requests_spec.rb" - "./spec/requests/api/package_files_spec.rb" -- "./spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb" -- "./spec/services/ci/create_pipeline_service/cross_project_pipeline_spec.rb" -- "./spec/services/ci/create_pipeline_service/needs_spec.rb" -- "./spec/services/ci/create_pipeline_service_spec.rb" -- "./spec/services/ci/destroy_pipeline_service_spec.rb" -- "./spec/services/ci/expire_pipeline_cache_service_spec.rb" -- "./spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb" -- "./spec/services/ci/job_artifacts/destroy_associations_service_spec.rb" -- "./spec/services/ci/job_artifacts/destroy_batch_service_spec.rb" -- "./spec/services/ci/pipeline_processing/shared_processing_service.rb" -- "./spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb" -- "./spec/services/ci/register_job_service_spec.rb" -- "./spec/services/clusters/applications/prometheus_config_service_spec.rb" -- "./spec/services/deployments/older_deployments_drop_service_spec.rb" -- "./spec/services/environments/auto_stop_service_spec.rb" - "./spec/services/environments/stop_service_spec.rb" -- "./spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb" -- "./spec/services/merge_requests/create_service_spec.rb" - "./spec/services/merge_requests/post_merge_service_spec.rb" -- "./spec/services/merge_requests/refresh_service_spec.rb" -- "./spec/support/prometheus/additional_metrics_shared_examples.rb" -- "./spec/support/shared_examples/ci/pipeline_email_shared_examples.rb" - "./spec/support/shared_examples/features/packages_shared_examples.rb" -- "./spec/support/shared_examples/features/search_settings_shared_examples.rb" -- "./spec/support/shared_examples/features/variable_list_shared_examples.rb" - "./spec/support/shared_examples/models/concerns/limitable_shared_examples.rb" -- "./spec/support/shared_examples/quick_actions/merge_request/merge_quick_action_shared_examples.rb" - "./spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb" - "./spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb" -- "./spec/support/shared_examples/requests/api/logging_application_context_shared_examples.rb" -- "./spec/support/shared_examples/requests/api/status_shared_examples.rb" - "./spec/support/shared_examples/requests/graphql_shared_examples.rb" -- "./spec/support/shared_examples/services/onboarding_progress_shared_examples.rb" - "./spec/support/shared_examples/services/packages_shared_examples.rb" -- "./spec/support/shared_examples/workers/idempotency_shared_examples.rb" -- "./spec/tasks/gitlab/generate_sample_prometheus_data_spec.rb" -- "./spec/workers/pipeline_process_worker_spec.rb" -- "./spec/workers/pipeline_schedule_worker_spec.rb" diff --git a/spec/support/database/multiple_databases.rb b/spec/support/database/multiple_databases.rb index 8ce642a682c..5e1ae60536f 100644 --- a/spec/support/database/multiple_databases.rb +++ b/spec/support/database/multiple_databases.rb @@ -5,5 +5,57 @@ module Database def skip_if_multiple_databases_not_setup skip 'Skipping because multiple databases not set up' unless Gitlab::Database.has_config?(:ci) end + + # The usage of this method switches temporarily used `connection_handler` + # allowing full manipulation of ActiveRecord::Base connections without + # having side effects like: + # - misaligned transactions since this is managed by `BeforeAllAdapter` + # - removal of primary connections + # + # The execution within a block ensures safe cleanup of all allocated resources. + # + # rubocop:disable Database/MultipleDatabases + def with_reestablished_active_record_base(reconnect: true) + connection_classes = ActiveRecord::Base.connection_handler.connection_pool_names.map(&:constantize).to_h do |klass| + [klass, klass.connection_db_config] + end + + original_handler = ActiveRecord::Base.connection_handler + new_handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new + ActiveRecord::Base.connection_handler = new_handler + + if reconnect + connection_classes.each { |klass, db_config| klass.establish_connection(db_config) } + end + + yield + ensure + ActiveRecord::Base.connection_handler = original_handler + new_handler&.clear_all_connections! + end + # rubocop:enable Database/MultipleDatabases + end + + module ActiveRecordBaseEstablishConnection + def establish_connection(*args) + # rubocop:disable Database/MultipleDatabases + if connected? && connection&.transaction_open? && ActiveRecord::Base.connection_handler == ActiveRecord::Base.default_connection_handler + raise "Cannot re-establish '#{self}.establish_connection' within an open transaction (#{connection&.open_transactions.to_i}). " \ + "Use `with_reestablished_active_record_base` instead or add `:reestablished_active_record_base` to rspec context." + end + # rubocop:enable Database/MultipleDatabases + + super + end end end + +RSpec.configure do |config| + config.around(:each, :reestablished_active_record_base) do |example| + with_reestablished_active_record_base(reconnect: example.metadata.fetch(:reconnect, true)) do + example.run + end + end +end + +ActiveRecord::Base.singleton_class.prepend(::Database::ActiveRecordBaseEstablishConnection) # rubocop:disable Database/MultipleDatabases diff --git a/spec/support/database/prevent_cross_database_modification.rb b/spec/support/database/prevent_cross_database_modification.rb index b4c968e3c41..7ded85b65ce 100644 --- a/spec/support/database/prevent_cross_database_modification.rb +++ b/spec/support/database/prevent_cross_database_modification.rb @@ -33,8 +33,10 @@ module Database end def cleanup_with_cross_database_modification_prevented - ActiveSupport::Notifications.unsubscribe(PreventCrossDatabaseModification.cross_database_context[:subscriber]) - PreventCrossDatabaseModification.cross_database_context[:enabled] = false + if PreventCrossDatabaseModification.cross_database_context + ActiveSupport::Notifications.unsubscribe(PreventCrossDatabaseModification.cross_database_context[:subscriber]) + PreventCrossDatabaseModification.cross_database_context[:enabled] = false + end end end @@ -55,8 +57,11 @@ module Database end def self.prevent_cross_database_modification!(connection, sql) + return unless cross_database_context return unless cross_database_context[:enabled] + return if connection.pool.instance_of?(ActiveRecord::ConnectionAdapters::NullPool) + database = connection.pool.db_config.name if sql.start_with?('SAVEPOINT') @@ -74,6 +79,8 @@ module Database return if cross_database_context[:transaction_depth_by_db].values.all?(&:zero?) + # PgQuery might fail in some cases due to limited nesting: + # https://github.com/pganalyze/pg_query/issues/209 parsed_query = PgQuery.parse(sql) tables = sql.downcase.include?(' for update') ? parsed_query.tables : parsed_query.dml_tables @@ -87,7 +94,8 @@ module Database if schemas.many? raise Database::PreventCrossDatabaseModification::CrossDatabaseModificationAcrossUnsupportedTablesError, "Cross-database data modification of '#{schemas.to_a.join(", ")}' were detected within " \ - "a transaction modifying the '#{all_tables.to_a.join(", ")}'" + "a transaction modifying the '#{all_tables.to_a.join(", ")}' tables." \ + "Please refer to https://docs.gitlab.com/ee/development/database/multiple_databases.html#removing-cross-database-transactions for details on how to resolve this exception." end end end @@ -96,16 +104,20 @@ end Gitlab::Database.singleton_class.prepend( Database::PreventCrossDatabaseModification::GitlabDatabaseMixin) +CROSS_DB_MODIFICATION_ALLOW_LIST = Set.new(YAML.load_file(File.join(__dir__, 'cross-database-modification-allowlist.yml'))).freeze + RSpec.configure do |config| config.include(::Database::PreventCrossDatabaseModification::SpecHelpers) # Using before and after blocks because the around block causes problems with the let_it_be # record creations. It makes an extra savepoint which breaks the transaction count logic. - config.before(:each, :prevent_cross_database_modification) do - with_cross_database_modification_prevented + config.before do |example_file| + if CROSS_DB_MODIFICATION_ALLOW_LIST.exclude?(example_file.file_path) + with_cross_database_modification_prevented + end end - config.after(:each, :prevent_cross_database_modification) do + config.after do |example_file| cleanup_with_cross_database_modification_prevented end end diff --git a/spec/support/database/prevent_cross_joins.rb b/spec/support/database/prevent_cross_joins.rb index 4b78aa9014c..f5ed2a8f22e 100644 --- a/spec/support/database/prevent_cross_joins.rb +++ b/spec/support/database/prevent_cross_joins.rb @@ -22,9 +22,10 @@ module Database CrossJoinAcrossUnsupportedTablesError = Class.new(StandardError) ALLOW_THREAD_KEY = :allow_cross_joins_across_databases + ALLOW_ANNOTATE_KEY = ALLOW_THREAD_KEY.to_s.freeze def self.validate_cross_joins!(sql) - return if Thread.current[ALLOW_THREAD_KEY] + return if Thread.current[ALLOW_THREAD_KEY] || sql.include?(ALLOW_ANNOTATE_KEY) # Allow spec/support/database_cleaner.rb queries to disable/enable triggers for many tables # See https://gitlab.com/gitlab-org/gitlab/-/issues/339396 @@ -32,21 +33,14 @@ module Database # PgQuery might fail in some cases due to limited nesting: # https://github.com/pganalyze/pg_query/issues/209 - # - # Also, we disable GC while parsing because of https://github.com/pganalyze/pg_query/issues/226 - begin - GC.disable - tables = PgQuery.parse(sql).tables - ensure - GC.enable - end + tables = PgQuery.parse(sql).tables schemas = Database::GitlabSchema.table_schemas(tables) if schemas.include?(:gitlab_ci) && schemas.include?(:gitlab_main) Thread.current[:has_cross_join_exception] = true raise CrossJoinAcrossUnsupportedTablesError, - "Unsupported cross-join across '#{tables.join(", ")}' modifying '#{schemas.to_a.join(", ")}' discovered " \ + "Unsupported cross-join across '#{tables.join(", ")}' querying '#{schemas.to_a.join(", ")}' discovered " \ "when executing query '#{sql}'. Please refer to https://docs.gitlab.com/ee/development/database/multiple_databases.html#removing-joins-between-ci_-and-non-ci_-tables for details on how to resolve this exception." end end @@ -63,6 +57,10 @@ module Database ensure ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber end + + def allow_cross_joins_across_databases(url:, &block) + ::Gitlab::Database.allow_cross_joins_across_databases(url: url, &block) + end end module GitlabDatabaseMixin @@ -75,12 +73,21 @@ module Database Thread.current[ALLOW_THREAD_KEY] = old_value end end + + module ActiveRecordRelationMixin + def allow_cross_joins_across_databases(url:) + super.annotate(ALLOW_ANNOTATE_KEY) + end + end end end Gitlab::Database.singleton_class.prepend( Database::PreventCrossJoins::GitlabDatabaseMixin) +ActiveRecord::Relation.prepend( + Database::PreventCrossJoins::ActiveRecordRelationMixin) + ALLOW_LIST = Set.new(YAML.load_file(File.join(__dir__, 'cross-join-allowlist.yml'))).freeze RSpec.configure do |config| diff --git a/spec/support/database_cleaner.rb b/spec/support/database_cleaner.rb index b31881e3082..8f706fdebc9 100644 --- a/spec/support/database_cleaner.rb +++ b/spec/support/database_cleaner.rb @@ -17,32 +17,9 @@ RSpec.configure do |config| delete_from_all_tables!(except: ['work_item_types']) # Postgres maximum number of columns in a table is 1600 (https://github.com/postgres/postgres/blob/de41869b64d57160f58852eab20a27f248188135/src/include/access/htup_details.h#L23-L47). - # And since: - # "The DROP COLUMN form does not physically remove the column, but simply makes - # it invisible to SQL operations. Subsequent insert and update operations in the - # table will store a null value for the column. Thus, dropping a column is quick - # but it will not immediately reduce the on-disk size of your table, as the space - # occupied by the dropped column is not reclaimed. - # The space will be reclaimed over time as existing rows are updated." - # according to https://www.postgresql.org/docs/current/sql-altertable.html. # We drop and recreate the database if any table has more than 1200 columns, just to be safe. - max_allowed_columns = 1200 - tables_with_more_than_allowed_columns = - ApplicationRecord.connection.execute("SELECT attrelid::regclass::text AS table, COUNT(*) AS column_count FROM pg_attribute GROUP BY attrelid HAVING COUNT(*) > #{max_allowed_columns}") - - if tables_with_more_than_allowed_columns.any? - tables_with_more_than_allowed_columns.each do |result| - puts "The #{result['table']} table has #{result['column_count']} columns." - end - puts "Recreating the database" - start = Gitlab::Metrics::System.monotonic_time - - ActiveRecord::Tasks::DatabaseTasks.drop_current - ActiveRecord::Tasks::DatabaseTasks.create_current - ActiveRecord::Tasks::DatabaseTasks.load_schema_current - ActiveRecord::Tasks::DatabaseTasks.migrate - - puts "Database re-creation done in #{Gitlab::Metrics::System.monotonic_time - start}" + if any_connection_class_with_more_than_allowed_columns? + recreate_all_databases! end end diff --git a/spec/support/database_load_balancing.rb b/spec/support/database_load_balancing.rb index f22c69ea613..014575e8a82 100644 --- a/spec/support/database_load_balancing.rb +++ b/spec/support/database_load_balancing.rb @@ -1,22 +1,30 @@ # frozen_string_literal: true RSpec.configure do |config| - config.before(:each, :db_load_balancing) do - allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(true) + config.around(:each, :database_replica) do |example| + old_proxies = [] - config = Gitlab::Database::LoadBalancing::Configuration - .new(ActiveRecord::Base, [Gitlab::Database.main.config['host']]) - lb = ::Gitlab::Database::LoadBalancing::LoadBalancer.new(config) - proxy = ::Gitlab::Database::LoadBalancing::ConnectionProxy.new(lb) + Gitlab::Database::LoadBalancing.base_models.each do |model| + config = Gitlab::Database::LoadBalancing::Configuration + .new(model, [model.connection_db_config.configuration_hash[:host]]) + lb = Gitlab::Database::LoadBalancing::LoadBalancer.new(config) - allow(ActiveRecord::Base).to receive(:load_balancing_proxy).and_return(proxy) + old_proxies << [model, model.connection] - ::Gitlab::Database::LoadBalancing::Session.clear_session + model.connection = + Gitlab::Database::LoadBalancing::ConnectionProxy.new(lb) + end + + Gitlab::Database::LoadBalancing::Session.clear_session redis_shared_state_cleanup! - end - config.after(:each, :db_load_balancing) do - ::Gitlab::Database::LoadBalancing::Session.clear_session + example.run + + Gitlab::Database::LoadBalancing::Session.clear_session redis_shared_state_cleanup! + + old_proxies.each do |(model, proxy)| + model.connection = proxy + end end end diff --git a/spec/support/db_cleaner.rb b/spec/support/db_cleaner.rb index 940ff2751d3..316d645f99f 100644 --- a/spec/support/db_cleaner.rb +++ b/spec/support/db_cleaner.rb @@ -2,7 +2,7 @@ module DbCleaner def all_connection_classes - ::ActiveRecord::Base.connection_handler.connection_pool_names.map(&:constantize) + ::BeforeAllAdapter.all_connection_classes end def delete_from_all_tables!(except: []) @@ -20,6 +20,79 @@ module DbCleaner DatabaseCleaner[:active_record, { connection: connection_class }] end end + + def any_connection_class_with_more_than_allowed_columns? + all_connection_classes.any? do |connection_class| + more_than_allowed_columns?(connection_class) + end + end + + def more_than_allowed_columns?(connection_class) + # Postgres maximum number of columns in a table is 1600 (https://github.com/postgres/postgres/blob/de41869b64d57160f58852eab20a27f248188135/src/include/access/htup_details.h#L23-L47). + # And since: + # "The DROP COLUMN form does not physically remove the column, but simply makes + # it invisible to SQL operations. Subsequent insert and update operations in the + # table will store a null value for the column. Thus, dropping a column is quick + # but it will not immediately reduce the on-disk size of your table, as the space + # occupied by the dropped column is not reclaimed. + # The space will be reclaimed over time as existing rows are updated." + # according to https://www.postgresql.org/docs/current/sql-altertable.html. + # We drop and recreate the database if any table has more than 1200 columns, just to be safe. + max_allowed_columns = 1200 + tables_with_more_than_allowed_columns = connection_class.connection.execute(<<-SQL) + SELECT attrelid::regclass::text AS table, COUNT(*) AS column_count + FROM pg_attribute + GROUP BY attrelid + HAVING COUNT(*) > #{max_allowed_columns} + SQL + + tables_with_more_than_allowed_columns.each do |result| + puts "The #{result['table']} (#{connection_class.connection_db_config.name}) table has #{result['column_count']} columns." + end + + tables_with_more_than_allowed_columns.any? + end + + def recreate_all_databases! + start = Gitlab::Metrics::System.monotonic_time + + puts "Recreating the database" + + force_disconnect_all_connections! + + ActiveRecord::Tasks::DatabaseTasks.drop_current + ActiveRecord::Tasks::DatabaseTasks.create_current + ActiveRecord::Tasks::DatabaseTasks.load_schema_current + + # Migrate each database individually + with_reestablished_active_record_base do + all_connection_classes.each do |connection_class| + ActiveRecord::Base.establish_connection(connection_class.connection_db_config) + + ActiveRecord::Tasks::DatabaseTasks.migrate + end + end + + puts "Databases re-creation done in #{Gitlab::Metrics::System.monotonic_time - start}" + end + + def force_disconnect_all_connections! + all_connection_classes.each do |connection_class| + # We use `connection_pool` to avoid going through + # Load Balancer since it does retry ops + pool = connection_class.connection_pool + + # Force disconnect https://www.cybertec-postgresql.com/en/terminating-database-connections-in-postgresql/ + pool.connection.execute(<<-SQL) + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = #{pool.connection.quote(pool.db_config.database)} + AND pid != pg_backend_pid(); + SQL + + connection_class.connection_pool.disconnect! + end + end end DbCleaner.prepend_mod_with('DbCleaner') diff --git a/spec/support/helpers/dependency_proxy_helpers.rb b/spec/support/helpers/dependency_proxy_helpers.rb index 9413cb93199..75dc09ec159 100644 --- a/spec/support/helpers/dependency_proxy_helpers.rb +++ b/spec/support/helpers/dependency_proxy_helpers.rb @@ -34,12 +34,20 @@ module DependencyProxyHelpers def build_jwt(user = nil, expire_time: nil) JSONWebToken::HMACToken.new(::Auth::DependencyProxyAuthenticationService.secret).tap do |jwt| - jwt['user_id'] = user.id if user.is_a?(User) - jwt['deploy_token'] = user.token if user.is_a?(DeployToken) - jwt.expire_time = expire_time || jwt.issued_at + 1.minute + if block_given? + yield(jwt) + else + jwt['user_id'] = user.id if user.is_a?(User) + jwt['deploy_token'] = user.token if user.is_a?(DeployToken) + jwt.expire_time = expire_time || jwt.issued_at + 1.minute + end end end + def jwt_token_authorization_headers(jwt) + { 'AUTHORIZATION' => "Bearer #{jwt.encoded}" } + end + private def registry diff --git a/spec/support/helpers/feature_flag_helpers.rb b/spec/support/helpers/feature_flag_helpers.rb index 51ba9039b70..4e57002a7c6 100644 --- a/spec/support/helpers/feature_flag_helpers.rb +++ b/spec/support/helpers/feature_flag_helpers.rb @@ -71,7 +71,7 @@ module FeatureFlagHelpers end def add_linked_issue_button - find('.js-issue-count-badge-add-button') + find_button 'Add a related issue' end def remove_linked_issue_button diff --git a/spec/support/helpers/javascript_fixtures_helpers.rb b/spec/support/helpers/javascript_fixtures_helpers.rb index 5174c145a93..fb909008f12 100644 --- a/spec/support/helpers/javascript_fixtures_helpers.rb +++ b/spec/support/helpers/javascript_fixtures_helpers.rb @@ -3,6 +3,8 @@ require 'action_dispatch/testing/test_request' require 'fileutils' +require_relative '../../../lib/gitlab/popen' + module JavaScriptFixturesHelpers extend ActiveSupport::Concern include Gitlab::Popen @@ -25,17 +27,6 @@ module JavaScriptFixturesHelpers 'tmp/tests/frontend/fixtures' + (Gitlab.ee? ? '-ee' : '') end - # Public: Removes all fixture files from given directory - # - # directory_name - directory of the fixtures (relative to .fixture_root_path) - # - def clean_frontend_fixtures(directory_name) - full_directory_name = File.expand_path(directory_name, fixture_root_path) - Dir[File.expand_path('*.{html,json,md}', full_directory_name)].each do |file_name| - FileUtils.rm(file_name) - end - end - def remove_repository(project) Gitlab::Shell.new.remove_repository(project.repository_storage, project.disk_path) end diff --git a/spec/support/helpers/navbar_structure_helper.rb b/spec/support/helpers/navbar_structure_helper.rb index 826108a63a5..96e79427278 100644 --- a/spec/support/helpers/navbar_structure_helper.rb +++ b/spec/support/helpers/navbar_structure_helper.rb @@ -52,4 +52,12 @@ module NavbarStructureHelper new_sub_nav_item_name: _('Infrastructure Registry') ) end + + def insert_infrastructure_google_cloud_nav + insert_after_sub_nav_item( + _('Terraform'), + within: _('Infrastructure'), + new_sub_nav_item_name: _('Google Cloud') + ) + end end diff --git a/spec/support/helpers/stub_gitlab_calls.rb b/spec/support/helpers/stub_gitlab_calls.rb index 5ab778c11cb..6f530d57caf 100644 --- a/spec/support/helpers/stub_gitlab_calls.rb +++ b/spec/support/helpers/stub_gitlab_calls.rb @@ -79,6 +79,18 @@ module StubGitlabCalls end end + def stub_container_registry_info(info: {}) + allow(ContainerRegistry::Client) + .to receive(:registry_info) + .and_return(info) + end + + def stub_container_registry_network_error(client_method:) + allow_next_instance_of(ContainerRegistry::Client) do |client| + allow(client).to receive(client_method).and_raise(::Faraday::Error, nil, nil) + end + end + def stub_commonmark_sourcepos_disabled allow_any_instance_of(Banzai::Filter::MarkdownEngines::CommonMark) .to receive(:render_options) diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb index b1a9aade043..5ead1813439 100644 --- a/spec/support/helpers/usage_data_helpers.rb +++ b/spec/support/helpers/usage_data_helpers.rb @@ -54,15 +54,8 @@ module UsageDataHelpers clusters_platforms_eks clusters_platforms_gke clusters_platforms_user - clusters_applications_helm - clusters_applications_ingress - clusters_applications_cert_managers - clusters_applications_prometheus - clusters_applications_crossplane - clusters_applications_runner - clusters_applications_knative - clusters_applications_elastic_stack - clusters_applications_jupyter + clusters_integrations_elastic_stack + clusters_integrations_prometheus clusters_management_project in_review_folder grafana_integrated_projects @@ -112,6 +105,7 @@ module UsageDataHelpers projects_with_expiration_policy_enabled_with_older_than_set_to_7d projects_with_expiration_policy_enabled_with_older_than_set_to_14d projects_with_expiration_policy_enabled_with_older_than_set_to_30d + projects_with_expiration_policy_enabled_with_older_than_set_to_60d projects_with_expiration_policy_enabled_with_older_than_set_to_90d projects_with_expiration_policy_enabled_with_cadence_set_to_1d projects_with_expiration_policy_enabled_with_cadence_set_to_7d diff --git a/spec/support/matchers/be_request_urgency.rb b/spec/support/matchers/be_request_urgency.rb new file mode 100644 index 00000000000..e88f157366b --- /dev/null +++ b/spec/support/matchers/be_request_urgency.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +RSpec::Matchers.define :be_request_urgency do |expected| + match do |actual| + actual.is_a?(::Gitlab::EndpointAttributes::Config::RequestUrgency) && + actual.name == expected + end +end diff --git a/spec/support/matchers/graphql_matchers.rb b/spec/support/matchers/graphql_matchers.rb index 904b7efdd7f..dcaec176687 100644 --- a/spec/support/matchers/graphql_matchers.rb +++ b/spec/support/matchers/graphql_matchers.rb @@ -3,14 +3,30 @@ RSpec::Matchers.define_negated_matcher :be_nullable, :be_non_null RSpec::Matchers.define :require_graphql_authorizations do |*expected| + def permissions_for(klass) + if klass.respond_to?(:required_permissions) + klass.required_permissions + else + [klass.to_graphql.metadata[:authorize]] + end + end + match do |klass| - permissions = if klass.respond_to?(:required_permissions) - klass.required_permissions - else - [klass.to_graphql.metadata[:authorize]] - end + actual = permissions_for(klass) + + expect(actual).to match_array(expected) + end + + failure_message do |klass| + actual = permissions_for(klass) + missing = actual - expected + extra = expected - actual - expect(permissions).to eq(expected) + message = [] + message << "is missing permissions: #{missing.inspect}" if missing.any? + message << "contained unexpected permissions: #{extra.inspect}" if extra.any? + + message.join("\n") end end diff --git a/spec/support/matchers/markdown_matchers.rb b/spec/support/matchers/markdown_matchers.rb index dfdb5bc01ae..f01c4075eeb 100644 --- a/spec/support/matchers/markdown_matchers.rb +++ b/spec/support/matchers/markdown_matchers.rb @@ -41,7 +41,7 @@ module MarkdownMatchers set_default_markdown_messages match do |actual| - expect(actual).to have_selector('gl-emoji', count: 10) + expect(actual).to have_selector('gl-emoji', count: 12) emoji_element = actual.at_css('gl-emoji') expect(emoji_element['data-name'].to_s).not_to be_empty diff --git a/spec/support/redis.rb b/spec/support/redis.rb index eeeb93fa811..421079af8e0 100644 --- a/spec/support/redis.rb +++ b/spec/support/redis.rb @@ -38,4 +38,20 @@ RSpec.configure do |config| redis_trace_chunks_cleanup! end + + config.around(:each, :clean_gitlab_redis_rate_limiting) do |example| + redis_rate_limiting_cleanup! + + example.run + + redis_rate_limiting_cleanup! + end + + config.around(:each, :clean_gitlab_redis_sessions) do |example| + redis_sessions_cleanup! + + example.run + + redis_sessions_cleanup! + end end diff --git a/spec/support/redis/redis_helpers.rb b/spec/support/redis/redis_helpers.rb index 3511d906203..f27d873eb31 100644 --- a/spec/support/redis/redis_helpers.rb +++ b/spec/support/redis/redis_helpers.rb @@ -22,4 +22,14 @@ module RedisHelpers def redis_trace_chunks_cleanup! Gitlab::Redis::TraceChunks.with(&:flushdb) end + + # Usage: rate limiting state (for Rack::Attack) + def redis_rate_limiting_cleanup! + Gitlab::Redis::RateLimiting.with(&:flushdb) + end + + # Usage: session state + def redis_sessions_cleanup! + Gitlab::Redis::Sessions.with(&:flushdb) + end end diff --git a/spec/support/redis/redis_new_instance_shared_examples.rb b/spec/support/redis/redis_new_instance_shared_examples.rb new file mode 100644 index 00000000000..e9b1e3e4da1 --- /dev/null +++ b/spec/support/redis/redis_new_instance_shared_examples.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.shared_examples "redis_new_instance_shared_examples" do |name, fallback_class| + let(:instance_specific_config_file) { "config/redis.#{name}.yml" } + let(:environment_config_file_name) { "GITLAB_REDIS_#{name.upcase}_CONFIG_FILE" } + let(:fallback_config_file) { nil } + + before do + allow(fallback_class).to receive(:config_file_name).and_return(fallback_config_file) + end + + include_examples "redis_shared_examples" + + describe '.config_file_name' do + subject { described_class.config_file_name } + + let(:rails_root) { Dir.mktmpdir('redis_shared_examples') } + + before do + # Undo top-level stub of config_file_name because we are testing that method now. + allow(described_class).to receive(:config_file_name).and_call_original + + allow(described_class).to receive(:rails_root).and_return(rails_root) + FileUtils.mkdir_p(File.join(rails_root, 'config')) + end + + after do + FileUtils.rm_rf(rails_root) + end + + context 'when there is only a resque.yml' do + before do + FileUtils.touch(File.join(rails_root, 'config/resque.yml')) + end + + it { expect(subject).to eq("#{rails_root}/config/resque.yml") } + + context 'and there is a global env override' do + before do + stub_env('GITLAB_REDIS_CONFIG_FILE', 'global override') + end + + it { expect(subject).to eq('global override') } + + context "and #{fallback_class.name.demodulize} has a different config file" do + let(:fallback_config_file) { 'fallback config file' } + + it { expect(subject).to eq('fallback config file') } + end + end + end + end +end diff --git a/spec/support/redis/redis_shared_examples.rb b/spec/support/redis/redis_shared_examples.rb index 25eab5fd6e4..dd916aea3e8 100644 --- a/spec/support/redis/redis_shared_examples.rb +++ b/spec/support/redis/redis_shared_examples.rb @@ -255,6 +255,28 @@ RSpec.shared_examples "redis_shared_examples" do end end + describe '#db' do + let(:rails_env) { 'development' } + + subject { described_class.new(rails_env).db } + + context 'with old format' do + let(:config_file_name) { config_old_format_host } + + it 'returns the correct db' do + expect(subject).to eq(redis_database) + end + end + + context 'with new format' do + let(:config_file_name) { config_new_format_host } + + it 'returns the correct db' do + expect(subject).to eq(redis_database) + end + end + end + describe '#sentinels' do subject { described_class.new(rails_env).sentinels } @@ -327,6 +349,12 @@ RSpec.shared_examples "redis_shared_examples" do expect(subject.send(:fetch_config)).to eq false end + + it 'has a value for the legacy default URL' do + allow(subject).to receive(:fetch_config) { false } + + expect(subject.send(:raw_config_hash)).to include(url: a_string_matching(%r{\Aredis://localhost:638[012]\Z})) + end end def clear_raw_config diff --git a/spec/support/shared_contexts/bulk_imports_requests_shared_context.rb b/spec/support/shared_contexts/bulk_imports_requests_shared_context.rb new file mode 100644 index 00000000000..62d708420c3 --- /dev/null +++ b/spec/support/shared_contexts/bulk_imports_requests_shared_context.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +RSpec.shared_context 'bulk imports requests context' do |url| + let(:page_response_headers) do + { + 'Content-Type' => 'application/json', + 'X-Next-Page' => 2, + 'X-Page' => 1, + 'X-Per-Page' => 20, + 'X-Total' => 42, + 'X-Total-Pages' => 2 + } + end + + let(:request_headers) { { 'Authorization' => 'Bearer demo-pat', 'Content-Type' => 'application/json' } } + + before do + stub_request(:get, "#{url}/api/v4/version") + .with(headers: request_headers) + .to_return( + status: 200, + body: { version: ::BulkImport.min_gl_version_for_project_migration.to_s }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + stub_request(:get, "https://gitlab.example.com/api/v4/groups?min_access_level=50&page=1&per_page=20&search=test&top_level_only=true") + .with(headers: request_headers) + .to_return(status: 200, + body: [{ + id: 2595440, + web_url: 'https://gitlab.com/groups/test', + name: 'Test', + path: 'stub-test-group', + full_name: 'Test', + full_path: 'stub-test-group' + }].to_json, + headers: page_response_headers + ) + + stub_request(:get, "%{url}/api/v4/groups?page=1&per_page=20&top_level_only=true&min_access_level=50&search=" % { url: url }) + .to_return( + body: [{ + id: 2595438, + web_url: 'https://gitlab.com/groups/auto-breakfast', + name: 'Stub', + path: 'stub-group', + full_name: 'Stub', + full_path: 'stub-group' + }].to_json, + headers: page_response_headers + ) + end +end diff --git a/spec/support/shared_contexts/lib/gitlab/import_export/relation_tree_restorer_shared_context.rb b/spec/support/shared_contexts/lib/gitlab/import_export/relation_tree_restorer_shared_context.rb index 6b9ddc70691..7176e38bf7c 100644 --- a/spec/support/shared_contexts/lib/gitlab/import_export/relation_tree_restorer_shared_context.rb +++ b/spec/support/shared_contexts/lib/gitlab/import_export/relation_tree_restorer_shared_context.rb @@ -3,7 +3,7 @@ RSpec.shared_context 'relation tree restorer shared context' do include ImportExport::CommonUtil - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } let(:shared) { Gitlab::ImportExport::Shared.new(importable) } let(:attributes) { relation_reader.consume_attributes(importable_name) } diff --git a/spec/support/shared_contexts/lib/gitlab/sidekiq_logging/structured_logger_shared_context.rb b/spec/support/shared_contexts/lib/gitlab/sidekiq_logging/structured_logger_shared_context.rb index 5a72b330707..b7966e25b38 100644 --- a/spec/support/shared_contexts/lib/gitlab/sidekiq_logging/structured_logger_shared_context.rb +++ b/spec/support/shared_contexts/lib/gitlab/sidekiq_logging/structured_logger_shared_context.rb @@ -39,17 +39,25 @@ RSpec.shared_context 'structured_logger' do ) end + let(:db_payload_defaults) do + metrics = + ::Gitlab::Metrics::Subscribers::ActiveRecord.load_balancing_metric_counter_keys + + ::Gitlab::Metrics::Subscribers::ActiveRecord.load_balancing_metric_duration_keys + + ::Gitlab::Metrics::Subscribers::ActiveRecord.db_counter_keys + + [:db_duration_s] + + metrics.each_with_object({}) do |key, result| + result[key.to_s] = 0 + end + end + let(:end_payload) do - start_payload.merge( + start_payload.merge(db_payload_defaults).merge( 'message' => 'TestWorker JID-da883554ee4fe414012f5f42: done: 0.0 sec', 'job_status' => 'done', 'duration_s' => 0.0, 'completed_at' => timestamp.to_f, - 'cpu_s' => 1.111112, - 'db_duration_s' => 0.0, - 'db_cached_count' => 0, - 'db_count' => 0, - 'db_write_count' => 0 + 'cpu_s' => 1.111112 ) end diff --git a/spec/support/shared_contexts/lib/gitlab/sidekiq_middleware/server_metrics_shared_context.rb b/spec/support/shared_contexts/lib/gitlab/sidekiq_middleware/server_metrics_shared_context.rb index 73de631e293..0d992f33c61 100644 --- a/spec/support/shared_contexts/lib/gitlab/sidekiq_middleware/server_metrics_shared_context.rb +++ b/spec/support/shared_contexts/lib/gitlab/sidekiq_middleware/server_metrics_shared_context.rb @@ -15,8 +15,12 @@ RSpec.shared_context 'server metrics with mocked prometheus' do let(:redis_seconds_metric) { double('redis seconds metric') } let(:elasticsearch_seconds_metric) { double('elasticsearch seconds metric') } let(:elasticsearch_requests_total) { double('elasticsearch calls total metric') } + let(:load_balancing_metric) { double('load balancing metric') } before do + allow(Gitlab::Metrics).to receive(:histogram).and_call_original + allow(Gitlab::Metrics).to receive(:counter).and_call_original + allow(Gitlab::Metrics).to receive(:histogram).with(:sidekiq_jobs_queue_duration_seconds, anything, anything, anything).and_return(queue_duration_seconds) allow(Gitlab::Metrics).to receive(:histogram).with(:sidekiq_jobs_completion_seconds, anything, anything, anything).and_return(completion_seconds_metric) allow(Gitlab::Metrics).to receive(:histogram).with(:sidekiq_jobs_cpu_seconds, anything, anything, anything).and_return(user_execution_seconds_metric) @@ -28,6 +32,7 @@ RSpec.shared_context 'server metrics with mocked prometheus' do allow(Gitlab::Metrics).to receive(:counter).with(:sidekiq_jobs_retried_total, anything).and_return(retried_total_metric) allow(Gitlab::Metrics).to receive(:counter).with(:sidekiq_redis_requests_total, anything).and_return(redis_requests_total) allow(Gitlab::Metrics).to receive(:counter).with(:sidekiq_elasticsearch_requests_total, anything).and_return(elasticsearch_requests_total) + allow(Gitlab::Metrics).to receive(:counter).with(:sidekiq_load_balancing_count, anything).and_return(load_balancing_metric) allow(Gitlab::Metrics).to receive(:gauge).with(:sidekiq_running_jobs, anything, {}, :all).and_return(running_jobs_metric) allow(Gitlab::Metrics).to receive(:gauge).with(:sidekiq_concurrency, anything, {}, :all).and_return(concurrency_metric) 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 de1b46c65ad..d7e4864cb08 100644 --- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb @@ -49,6 +49,7 @@ RSpec.shared_context 'ProjectPolicy context' do resolve_note update_build update_commit_status update_container_image update_deployment update_environment update_merge_request update_metrics_dashboard_annotation update_pipeline update_release destroy_release + read_resource_group update_resource_group ] end diff --git a/spec/support/shared_examples/ci/stuck_builds_shared_examples.rb b/spec/support/shared_examples/ci/stuck_builds_shared_examples.rb new file mode 100644 index 00000000000..4fcea18393c --- /dev/null +++ b/spec/support/shared_examples/ci/stuck_builds_shared_examples.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'job is dropped with failure reason' do |failure_reason| + it 'changes status' do + service.execute + job.reload + + expect(job).to be_failed + expect(job.failure_reason).to eq(failure_reason) + end + + context 'when job has data integrity problem' do + it 'drops the job and logs the reason' do + job.update_columns(yaml_variables: '[{"key" => "value"}]') + + expect(Gitlab::ErrorTracking) + .to receive(:track_exception) + .with(anything, a_hash_including(build_id: job.id)) + .once + .and_call_original + + service.execute + job.reload + + expect(job).to be_failed + expect(job.failure_reason).to eq('data_integrity_failure') + end + end +end + +RSpec.shared_examples 'job is unchanged' do + it 'does not change status' do + expect { service.execute }.not_to change(job, :status) + end +end diff --git a/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb b/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb index e8f7e62d0d7..30710e43357 100644 --- a/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb +++ b/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb @@ -299,7 +299,7 @@ RSpec.shared_examples 'wiki controller actions' do expect(response.headers['Content-Disposition']).to match(/^inline/) expect(response.headers[Gitlab::Workhorse::DETECT_HEADER]).to eq('true') expect(response.cache_control[:public]).to be(false) - expect(response.headers['Cache-Control']).to eq('no-store') + expect(response.headers['Cache-Control']).to eq('private, no-store') end end end diff --git a/spec/support/shared_examples/features/container_registry_shared_examples.rb b/spec/support/shared_examples/features/container_registry_shared_examples.rb new file mode 100644 index 00000000000..06b2b8c621c --- /dev/null +++ b/spec/support/shared_examples/features/container_registry_shared_examples.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'handling feature network errors with the container registry' do + it 'displays the error message' do + visit_container_registry + + expect(page).to have_content 'We are having trouble connecting to the Container Registry' + 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 318ba67b9e9..6c06cbf9082 100644 --- a/spec/support/shared_examples/features/discussion_comments_shared_example.rb +++ b/spec/support/shared_examples/features/discussion_comments_shared_example.rb @@ -3,9 +3,9 @@ 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" } + let(:toggle_selector) { "#{dropdown_selector} .gl-dropdown-toggle" } let(:menu_selector) { "#{dropdown_selector} .dropdown-menu" } - let(:submit_selector) { "#{form_selector} .js-comment-submit-button" } + let(:submit_selector) { "#{form_selector} .js-comment-submit-button > button:first-child" } let(:close_selector) { "#{form_selector} .btn-comment-and-close" } let(:comments_selector) { '.timeline > .note.timeline-entry:not(.being-posted)' } let(:comment) { 'My comment' } @@ -43,13 +43,11 @@ RSpec.shared_examples 'thread comments for commit and snippet' do |resource_name expect(items.first).to have_content 'Comment' expect(items.first).to have_content "Add a general comment to this #{resource_name}." - expect(items.first).to have_selector '[data-testid="check-icon"]' - expect(items.first['class']).to match 'droplab-item-selected' + expect(items.first).to have_selector '[data-testid="dropdown-item-checkbox"]' 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'}." - expect(items.last).not_to have_selector '[data-testid="check-icon"]' - expect(items.last['class']).not_to match 'droplab-item-selected' + expect(items.last).not_to have_selector '[data-testid="dropdown-item-checkbox"]' end it 'closes the menu when clicking the toggle or body' do @@ -75,14 +73,14 @@ RSpec.shared_examples 'thread comments for commit and snippet' do |resource_name expect(find(dropdown_selector)).to have_content 'Comment' find(toggle_selector).click - execute_script("document.querySelector('#{menu_selector} .divider').click()") + execute_script("document.querySelector('#{menu_selector} .dropdown-divider').click()") else execute_script("document.querySelector('#{menu_selector}').click()") expect(page).to have_selector menu_selector expect(find(dropdown_selector)).to have_content 'Comment' - execute_script("document.querySelector('#{menu_selector} .divider').click()") + execute_script("document.querySelector('#{menu_selector} .dropdown-divider').click()") expect(page).to have_selector menu_selector end @@ -97,7 +95,7 @@ RSpec.shared_examples 'thread comments for commit and snippet' do |resource_name end it 'updates the submit button text and closes the dropdown' do - expect(find(submit_selector).value).to eq 'Start thread' + expect(find(submit_selector).text).to eq 'Start thread' expect(page).not_to have_selector menu_selector end @@ -137,12 +135,10 @@ RSpec.shared_examples 'thread comments for commit and snippet' do |resource_name 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.first).not_to have_selector '[data-testid="dropdown-item-checkbox"]' 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' + expect(items.last).to have_selector '[data-testid="dropdown-item-checkbox"]' end describe 'when selecting "Comment"' do @@ -153,7 +149,7 @@ RSpec.shared_examples 'thread comments for commit and snippet' do |resource_name it 'updates the submit button text and closes the dropdown' do button = find(submit_selector) - expect(button.value).to eq 'Comment' + expect(button.text).to eq 'Comment' expect(page).not_to have_selector menu_selector end @@ -166,12 +162,10 @@ RSpec.shared_examples 'thread comments for commit and snippet' do |resource_name 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.first).to have_selector '[data-testid="dropdown-item-checkbox"]' 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' + expect(items.last).not_to have_selector '[data-testid="dropdown-item-checkbox"]' end end end 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 7adf303bde4..85434ba7afd 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 @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.shared_examples 'it uploads and commits a new text file' do +RSpec.shared_examples 'it uploads and commits a new text file' do |drop: false| it 'uploads and commits a new text file', :js do find('.add-to-tree').click @@ -10,7 +10,11 @@ RSpec.shared_examples 'it uploads and commits a new text file' do wait_for_requests end - attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true) + if drop + find(".upload-dropzone-card").drop(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt')) + else + attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true) + end page.within('#modal-upload-blob') do fill_in(:commit_message, with: 'New commit message') @@ -32,7 +36,7 @@ RSpec.shared_examples 'it uploads and commits a new text file' do end end -RSpec.shared_examples 'it uploads and commits a new image file' do +RSpec.shared_examples 'it uploads and commits a new image file' do |drop: false| it 'uploads and commits a new image file', :js do find('.add-to-tree').click @@ -42,7 +46,11 @@ RSpec.shared_examples 'it uploads and commits a new image file' do wait_for_requests end - attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'logo_sample.svg'), make_visible: true) + if drop + find(".upload-dropzone-card").drop(File.join(Rails.root, 'spec', 'fixtures', 'logo_sample.svg')) + else + attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'logo_sample.svg'), make_visible: true) + end page.within('#modal-upload-blob') do fill_in(:commit_message, with: 'New commit message') @@ -58,7 +66,7 @@ RSpec.shared_examples 'it uploads and commits a new image file' do end end -RSpec.shared_examples 'it uploads and commits a new pdf file' do +RSpec.shared_examples 'it uploads and commits a new pdf file' do |drop: false| it 'uploads and commits a new pdf file', :js do find('.add-to-tree').click @@ -68,7 +76,11 @@ RSpec.shared_examples 'it uploads and commits a new pdf file' do wait_for_requests end - attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'git-cheat-sheet.pdf'), make_visible: true) + if drop + find(".upload-dropzone-card").drop(File.join(Rails.root, 'spec', 'fixtures', 'git-cheat-sheet.pdf')) + else + attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'git-cheat-sheet.pdf'), make_visible: true) + end page.within('#modal-upload-blob') do fill_in(:commit_message, with: 'New commit message') @@ -84,7 +96,7 @@ RSpec.shared_examples 'it uploads and commits a new pdf file' do end end -RSpec.shared_examples 'it uploads and commits a new file to a forked project' do +RSpec.shared_examples 'it uploads and commits a new file to a forked project' do |drop: false| let(:fork_message) do "You're not allowed to make changes to this project directly. "\ "A fork of this project has been created that you can make changes in, so you can submit a merge request." @@ -100,7 +112,12 @@ RSpec.shared_examples 'it uploads and commits a new file to a forked project' do find('.add-to-tree').click click_link('Upload file') - attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true) + + if drop + find(".upload-dropzone-card").drop(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt')) + else + attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true) + end page.within('#modal-upload-blob') do fill_in(:commit_message, with: 'New commit message') @@ -123,7 +140,7 @@ RSpec.shared_examples 'it uploads and commits a new file to a forked project' do end end -RSpec.shared_examples 'it uploads a file to a sub-directory' do +RSpec.shared_examples 'it uploads a file to a sub-directory' do |drop: false| it 'uploads a file to a sub-directory', :js do click_link 'files' @@ -133,7 +150,12 @@ RSpec.shared_examples 'it uploads a file to a sub-directory' do find('.add-to-tree').click click_link('Upload file') - attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true) + + if drop + find(".upload-dropzone-card").drop(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt')) + else + attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true) + end page.within('#modal-upload-blob') do fill_in(:commit_message, with: 'New commit message') @@ -150,11 +172,15 @@ RSpec.shared_examples 'it uploads a file to a sub-directory' do end end -RSpec.shared_examples 'uploads and commits a new text file via "upload file" button' do +RSpec.shared_examples 'uploads and commits a new text file via "upload file" button' do |drop: false| 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) + if drop + find(".upload-dropzone-card").drop(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt')) + else + attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true) + end page.within('#details-modal-upload-blob') do fill_in(:commit_message, with: 'New commit message') diff --git a/spec/support/shared_examples/graphql/connection_shared_examples.rb b/spec/support/shared_examples/graphql/connection_shared_examples.rb index 4cba5b5a69d..895bab1f51a 100644 --- a/spec/support/shared_examples/graphql/connection_shared_examples.rb +++ b/spec/support/shared_examples/graphql/connection_shared_examples.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.shared_examples 'a connection with collection methods' do - %i[to_a size include? empty?].each do |method_name| + %i[to_a size map include? empty?].each do |method_name| it "responds to #{method_name}" do expect(connection).to respond_to(method_name) end diff --git a/spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb b/spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb index eaeb5faee3b..37a805902a9 100644 --- a/spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb +++ b/spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb @@ -9,7 +9,7 @@ # data_path: the keys necessary to dig into the return GraphQL data to get the # returned results # first_param: number of items expected (like a page size) -# expected_results: array of comparison data of all items sorted correctly +# all_records: array of comparison data of all items sorted correctly # pagination_query: method that specifies the GraphQL query # pagination_results_data: method that extracts the sorted data used to compare against # the expected results @@ -38,9 +38,9 @@ # let(:ordered_issues) { issues.sort_by(&:weight) } # # it_behaves_like 'sorted paginated query' do -# let(:sort_param) { :WEIGHT_ASC } -# let(:first_param) { 2 } -# let(:expected_results) { ordered_issues.map(&:iid) } +# let(:sort_param) { :WEIGHT_ASC } +# let(:first_param) { 2 } +# let(:all_records) { ordered_issues.map(&:iid) } # end # end # @@ -51,7 +51,7 @@ RSpec.shared_examples 'sorted paginated query' do |conditions = {}| let(:node_path) { ['id'] } it_behaves_like 'requires variables' do - let(:required_variables) { [:sort_param, :first_param, :expected_results, :data_path, :current_user] } + let(:required_variables) { [:sort_param, :first_param, :all_records, :data_path, :current_user] } end describe do @@ -101,13 +101,13 @@ RSpec.shared_examples 'sorted paginated query' do |conditions = {}| context 'when sorting' do it 'sorts correctly' do - expect(results).to eq expected_results + expect(results).to eq all_records end context 'when paginating' do let(:params) { sort_argument.merge(first: first_param) } - let(:first_page) { expected_results.first(first_param) } - let(:rest) { expected_results.drop(first_param) } + let(:first_page) { all_records.first(first_param) } + let(:rest) { all_records.drop(first_param) } it 'paginates correctly' do expect(results).to eq first_page @@ -130,7 +130,7 @@ RSpec.shared_examples 'sorted paginated query' do |conditions = {}| it 'fetches last elements without error' do post_graphql(pagination_query(params), current_user: current_user) - expect(results.first).to eq(expected_results.last) + expect(results.first).to eq(all_records.last) end end end diff --git a/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb index 3760325675a..8b4ecd7d5ae 100644 --- a/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb @@ -35,8 +35,8 @@ RSpec.shared_examples 'common trace features' do stub_feature_flags(gitlab_ci_archived_trace_consistent_reads: trace.job.project) end - it 'calls ::Gitlab::Database::LoadBalancing::Sticking.unstick_or_continue_sticking' do - expect(::Gitlab::Database::LoadBalancing::Sticking).to receive(:unstick_or_continue_sticking) + it 'calls ::ApplicationRecord.sticking.unstick_or_continue_sticking' do + expect(::ApplicationRecord.sticking).to receive(:unstick_or_continue_sticking) .with(described_class::LOAD_BALANCING_STICKING_NAMESPACE, trace.job.id) .and_call_original @@ -49,8 +49,8 @@ RSpec.shared_examples 'common trace features' do stub_feature_flags(gitlab_ci_archived_trace_consistent_reads: false) end - it 'does not call ::Gitlab::Database::LoadBalancing::Sticking.unstick_or_continue_sticking' do - expect(::Gitlab::Database::LoadBalancing::Sticking).not_to receive(:unstick_or_continue_sticking) + it 'does not call ::ApplicationRecord.sticking.unstick_or_continue_sticking' do + expect(::ApplicationRecord.sticking).not_to receive(:unstick_or_continue_sticking) trace.read { |stream| stream } end @@ -305,8 +305,8 @@ RSpec.shared_examples 'common trace features' do stub_feature_flags(gitlab_ci_archived_trace_consistent_reads: trace.job.project) end - it 'calls ::Gitlab::Database::LoadBalancing::Sticking.stick' do - expect(::Gitlab::Database::LoadBalancing::Sticking).to receive(:stick) + it 'calls ::ApplicationRecord.sticking.stick' do + expect(::ApplicationRecord.sticking).to receive(:stick) .with(described_class::LOAD_BALANCING_STICKING_NAMESPACE, trace.job.id) .and_call_original @@ -319,8 +319,8 @@ RSpec.shared_examples 'common trace features' do stub_feature_flags(gitlab_ci_archived_trace_consistent_reads: false) end - it 'does not call ::Gitlab::Database::LoadBalancing::Sticking.stick' do - expect(::Gitlab::Database::LoadBalancing::Sticking).not_to receive(:stick) + it 'does not call ::ApplicationRecord.sticking.stick' do + expect(::ApplicationRecord.sticking).not_to receive(:stick) subject end @@ -497,7 +497,7 @@ RSpec.shared_examples 'trace with disabled live trace feature' do expect(build.job_artifacts_trace.file.filename).to eq('job.log') expect(File.exist?(src_path)).to be_falsy expect(src_checksum) - .to eq(described_class.hexdigest(build.job_artifacts_trace.file.path)) + .to eq(described_class.sha256_hexdigest(build.job_artifacts_trace.file.path)) expect(build.job_artifacts_trace.file_sha256).to eq(src_checksum) end end @@ -523,7 +523,7 @@ RSpec.shared_examples 'trace with disabled live trace feature' do expect(build.job_artifacts_trace.file.filename).to eq('job.log') expect(build.old_trace).to be_nil expect(src_checksum) - .to eq(described_class.hexdigest(build.job_artifacts_trace.file.path)) + .to eq(described_class.sha256_hexdigest(build.job_artifacts_trace.file.path)) expect(build.job_artifacts_trace.file_sha256).to eq(src_checksum) end end @@ -861,7 +861,7 @@ RSpec.shared_examples 'trace with enabled live trace feature' do expect(build.job_artifacts_trace.file.filename).to eq('job.log') expect(Ci::BuildTraceChunk.where(build: build)).not_to be_exist expect(src_checksum) - .to eq(described_class.hexdigest(build.job_artifacts_trace.file.path)) + .to eq(described_class.sha256_hexdigest(build.job_artifacts_trace.file.path)) expect(build.job_artifacts_trace.file_sha256).to eq(src_checksum) 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 6e12b5a0e85..bd8bdd70ce5 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 @@ -33,3 +33,38 @@ RSpec.shared_examples_for 'value stream analytics event' do end end end + +RSpec.shared_examples_for 'LEFT JOIN-able value stream analytics event' do + let(:params) { {} } + let(:instance) { described_class.new(params) } + let(:record_with_data) { nil } + let(:record_without_data) { nil } + let(:scope) { instance.object_type.all } + + let(:records) do + scope_with_left_join = instance.include_in(scope) + scope_with_left_join.select(scope.model.arel_table[:id], instance.timestamp_projection.as('timestamp_column_data')).to_a + end + + it 'can use the event as LEFT JOIN' do + expected_record_count = record_without_data.nil? ? 1 : 2 + + expect(records.count).to eq(expected_record_count) + end + + context 'when looking at the record with data' do + subject(:record) { records.to_a.find { |r| r.id == record_with_data.id } } + + it 'contains the timestamp expression' do + expect(record.timestamp_column_data).not_to eq(nil) + end + end + + context 'when looking at the record without data' do + subject(:record) { records.to_a.find { |r| r.id == record_without_data.id } } + + it 'returns nil for the timestamp expression' do + expect(record.timestamp_column_data).to eq(nil) if record_without_data + end + end +end diff --git a/spec/support/shared_examples/lib/gitlab/import_export/attributes_permitter_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/import_export/attributes_permitter_shared_examples.rb new file mode 100644 index 00000000000..5ce698c4701 --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/import_export/attributes_permitter_shared_examples.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +RSpec.shared_examples 'a permitted attribute' do |relation_sym, permitted_attributes| + let(:prohibited_attributes) { %i[remote_url my_attributes my_ids token my_id test] } + + let(:import_export_config) { Gitlab::ImportExport::Config.new.to_h } + let(:project_relation_factory) { Gitlab::ImportExport::Project::RelationFactory } + + let(:relation_hash) { (permitted_attributes + prohibited_attributes).map(&:to_s).zip([]).to_h } + let(:relation_name) { project_relation_factory.overrides[relation_sym]&.to_sym || relation_sym } + let(:relation_class) { project_relation_factory.relation_class(relation_name) } + let(:excluded_keys) { import_export_config.dig(:excluded_keys, relation_sym) || [] } + + let(:cleaned_hash) do + Gitlab::ImportExport::AttributeCleaner.new( + relation_hash: relation_hash, + relation_class: relation_class, + excluded_keys: excluded_keys + ).clean + end + + let(:permitted_hash) { subject.permit(relation_sym, relation_hash) } + + if described_class.new.permitted_attributes_defined?(relation_sym) + it 'contains only attributes that are defined as permitted in the import/export config' do + expect(permitted_hash.keys).to contain_exactly(*permitted_attributes.map(&:to_s)) + end + + it 'does not contain attributes that would be cleaned with AttributeCleaner' do + expect(cleaned_hash.keys).to include(*permitted_hash.keys) + end + + it 'does not contain prohibited attributes that are not related to given relation' do + expect(permitted_hash.keys).not_to include(*prohibited_attributes.map(&:to_s)) + end + else + it 'is disabled' do + expect(subject).not_to be_permitted_attributes_defined(relation_sym) + 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 index c6d6ff6bc1d..c06083ba952 100644 --- a/spec/support/shared_examples/metrics/active_record_subscriber_shared_examples.rb +++ b/spec/support/shared_examples/metrics/active_record_subscriber_shared_examples.rb @@ -4,14 +4,20 @@ RSpec.shared_examples 'store ActiveRecord info in RequestStore' do |db_role| let(:db_config_name) { ::Gitlab::Database.db_config_names.first } let(:expected_payload_defaults) do + result = {} metrics = ::Gitlab::Metrics::Subscribers::ActiveRecord.load_balancing_metric_counter_keys + - ::Gitlab::Metrics::Subscribers::ActiveRecord.load_balancing_metric_duration_keys + ::Gitlab::Metrics::Subscribers::ActiveRecord.db_counter_keys - metrics.each_with_object({}) do |key, result| + metrics.each do |key| result[key] = 0 end + + ::Gitlab::Metrics::Subscribers::ActiveRecord.load_balancing_metric_duration_keys.each do |key| + result[key] = 0.0 + end + + result end def transform_hash(hash, another_hash) @@ -36,8 +42,8 @@ RSpec.shared_examples 'store ActiveRecord info in RequestStore' do |db_role| "db_primary_#{db_config_name}_cached_count": record_cached_query ? 1 : 0, db_primary_count: record_query ? 1 : 0, "db_primary_#{db_config_name}_count": record_query ? 1 : 0, - db_primary_duration_s: record_query ? 0.002 : 0, - "db_primary_#{db_config_name}_duration_s": record_query ? 0.002 : 0, + db_primary_duration_s: record_query ? 0.002 : 0.0, + "db_primary_#{db_config_name}_duration_s": record_query ? 0.002 : 0.0, db_primary_wal_count: record_wal_query ? 1 : 0, "db_primary_#{db_config_name}_wal_count": record_wal_query ? 1 : 0, db_primary_wal_cached_count: record_wal_query && record_cached_query ? 1 : 0, @@ -52,19 +58,29 @@ RSpec.shared_examples 'store ActiveRecord info in RequestStore' do |db_role| "db_replica_#{db_config_name}_cached_count": record_cached_query ? 1 : 0, db_replica_count: record_query ? 1 : 0, "db_replica_#{db_config_name}_count": record_query ? 1 : 0, - db_replica_duration_s: record_query ? 0.002 : 0, - "db_replica_#{db_config_name}_duration_s": record_query ? 0.002 : 0, + db_replica_duration_s: record_query ? 0.002 : 0.0, + "db_replica_#{db_config_name}_duration_s": record_query ? 0.002 : 0.0, db_replica_wal_count: record_wal_query ? 1 : 0, "db_replica_#{db_config_name}_wal_count": record_wal_query ? 1 : 0, db_replica_wal_cached_count: record_wal_query && record_cached_query ? 1 : 0, "db_replica_#{db_config_name}_wal_cached_count": record_wal_query && record_cached_query ? 1 : 0 }) else - { + transform_hash(expected_payload_defaults, { db_count: record_query ? 1 : 0, db_write_count: record_write_query ? 1 : 0, - db_cached_count: record_cached_query ? 1 : 0 - } + db_cached_count: record_cached_query ? 1 : 0, + db_primary_cached_count: 0, + "db_primary_#{db_config_name}_cached_count": 0, + db_primary_count: 0, + "db_primary_#{db_config_name}_count": 0, + db_primary_duration_s: 0.0, + "db_primary_#{db_config_name}_duration_s": 0.0, + db_primary_wal_count: 0, + "db_primary_#{db_config_name}_wal_count": 0, + db_primary_wal_cached_count: 0, + "db_primary_#{db_config_name}_wal_cached_count": 0 + }) end expect(described_class.db_counter_payload).to eq(expected) @@ -89,7 +105,7 @@ RSpec.shared_examples 'store ActiveRecord info in RequestStore' do |db_role| end RSpec.shared_examples 'record ActiveRecord metrics in a metrics transaction' do |db_role| - let(:db_config_name) { ::Gitlab::Database.db_config_name(ApplicationRecord.connection) } + let(:db_config_name) { ::Gitlab::Database.db_config_name(ApplicationRecord.retrieve_connection) } it 'increments only db counters' do if record_query diff --git a/spec/support/shared_examples/models/concerns/analytics/cycle_analytics/stage_event_model_examples.rb b/spec/support/shared_examples/models/concerns/analytics/cycle_analytics/stage_event_model_examples.rb new file mode 100644 index 00000000000..f928fb1eb43 --- /dev/null +++ b/spec/support/shared_examples/models/concerns/analytics/cycle_analytics/stage_event_model_examples.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'StageEventModel' do + describe '.upsert_data' do + let(:time) { Time.parse(Time.current.to_s(:db)) } # truncating the timestamp so we can compare it with the timestamp loaded from the DB + let(:input_data) do + [ + { + stage_event_hash_id: 1, + issuable_id: 2, + group_id: 3, + project_id: 4, + author_id: 5, + milestone_id: 6, + start_event_timestamp: time, + end_event_timestamp: time + }, + { + stage_event_hash_id: 7, + issuable_id: 8, + group_id: 10, + project_id: 11, + author_id: 12, + milestone_id: 13, + start_event_timestamp: time, + end_event_timestamp: time + } + ] + end + + let(:column_order) do + [ + :stage_event_hash_id, + described_class.issuable_id_column, + :group_id, + :project_id, + :milestone_id, + :author_id, + :start_event_timestamp, + :end_event_timestamp + ] + end + + subject(:upsert_data) { described_class.upsert_data(input_data) } + + it 'inserts the data' do + upsert_data + + expect(described_class.count).to eq(input_data.count) + end + + it 'does not produce duplicate rows' do + 2.times { upsert_data } + + expect(described_class.count).to eq(input_data.count) + end + + it 'inserts the data correctly' do + upsert_data + + output_data = described_class.all.map do |record| + column_order.map { |column| record[column] } + end.sort + + expect(input_data.map(&:values).sort).to eq(output_data) + end + end +end diff --git a/spec/support/shared_examples/models/concerns/ttl_expirable_shared_examples.rb b/spec/support/shared_examples/models/concerns/ttl_expirable_shared_examples.rb new file mode 100644 index 00000000000..a4e0d6c871e --- /dev/null +++ b/spec/support/shared_examples/models/concerns/ttl_expirable_shared_examples.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.shared_examples 'ttl_expirable' do + let_it_be(:class_symbol) { described_class.model_name.param_key.to_sym } + + it_behaves_like 'having unique enum values' + + describe 'validations' do + it { is_expected.to validate_presence_of(:status) } + end + + describe '.updated_before' do + # rubocop:disable Rails/SaveBang + let_it_be_with_reload(:item1) { create(class_symbol) } + let_it_be(:item2) { create(class_symbol) } + # rubocop:enable Rails/SaveBang + + before do + item1.update_column(:updated_at, 1.month.ago) + end + + it 'returns items with created at older than the supplied number of days' do + expect(described_class.updated_before(10)).to contain_exactly(item1) + end + end + + describe '.active' do + # rubocop:disable Rails/SaveBang + let_it_be(:item1) { create(class_symbol) } + let_it_be(:item2) { create(class_symbol, :expired) } + let_it_be(:item3) { create(class_symbol, status: :error) } + # rubocop:enable Rails/SaveBang + + it 'returns only active items' do + expect(described_class.active).to contain_exactly(item1) + end + end + + describe '.lock_next_by' do + let_it_be(:item1) { create(class_symbol, created_at: 1.month.ago, updated_at: 1.day.ago) } + let_it_be(:item2) { create(class_symbol, created_at: 1.year.ago, updated_at: 1.year.ago) } + let_it_be(:item3) { create(class_symbol, created_at: 2.years.ago, updated_at: 1.month.ago) } + + it 'returns the first item sorted by the argument' do + expect(described_class.lock_next_by(:updated_at)).to contain_exactly(item2) + expect(described_class.lock_next_by(:created_at)).to contain_exactly(item3) + end + end +end diff --git a/spec/support/shared_examples/models/packages/debian/distribution_shared_examples.rb b/spec/support/shared_examples/models/packages/debian/distribution_shared_examples.rb index 274fbae3dfd..750d3dd11e3 100644 --- a/spec/support/shared_examples/models/packages/debian/distribution_shared_examples.rb +++ b/spec/support/shared_examples/models/packages/debian/distribution_shared_examples.rb @@ -193,42 +193,6 @@ RSpec.shared_examples 'Debian Distribution' do |factory, container, can_freeze| end end - describe '#needs_update?' do - subject { distribution.needs_update? } - - context 'with new distribution' do - let(:distribution) { create(factory, container: distribution_with_suite.container) } - - it { is_expected.to be_truthy } - end - - context 'with file' do - context 'without valid_time_duration_seconds' do - let(:distribution) { create(factory, :with_file, container: distribution_with_suite.container) } - - it { is_expected.to be_falsey } - end - - context 'with valid_time_duration_seconds' do - let(:distribution) { create(factory, :with_file, container: distribution_with_suite.container, valid_time_duration_seconds: 2.days.to_i) } - - context 'when not yet expired' do - it { is_expected.to be_falsey } - end - - context 'when expired' do - it do - distribution - - travel_to(4.days.from_now) do - is_expected.to be_truthy - end - end - end - end - end - end - if container == :project describe 'project distribution specifics' do describe 'relationships' do diff --git a/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb index b86c0529338..e45be21f152 100644 --- a/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb @@ -85,7 +85,18 @@ RSpec.shared_examples 'Composer package creation' do |user_type, status, add_mem expect(response).to have_gitlab_http_status(status) end + it_behaves_like 'a package tracking event', described_class.name, 'push_package' + + context 'when package creation fails' do + before do + allow_next_instance_of(::Packages::Composer::CreatePackageService) do |create_package_service| + allow(create_package_service).to receive(:execute).and_raise(StandardError) + end + end + + it_behaves_like 'not a package tracking event' + end end end diff --git a/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb b/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb index e776cf13217..e1e75be2494 100644 --- a/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb @@ -79,3 +79,40 @@ RSpec.shared_examples 'returns repositories for allowed users' do |user_type, sc end end end + +RSpec.shared_examples 'handling network errors with the container registry' do + before do + stub_container_registry_network_error(client_method: :repository_tags) + end + + it 'returns a connection error' do + subject + + expect(response).to have_gitlab_http_status(:service_unavailable) + expect(json_response['message']).to include('We are having trouble connecting to the Container Registry') + end +end + +RSpec.shared_examples 'handling graphql network errors with the container registry' do + before do + stub_container_registry_network_error(client_method: :repository_tags) + end + + it 'returns a connection error' do + subject + + expect_graphql_errors_to_include('We are having trouble connecting to the Container Registry') + end +end + +RSpec.shared_examples 'not hitting graphql network errors with the container registry' do + before do + stub_container_registry_network_error(client_method: :repository_tags) + end + + it 'does not return any error' do + subject + + expect_graphql_errors_to_be_empty + end +end 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 274516cd87b..01ed6c26576 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 @@ -62,9 +62,10 @@ RSpec.shared_examples 'group and project boards query' do context 'when ascending' do it_behaves_like 'sorted paginated query' do - let(:sort_param) { } - let(:first_param) { 2 } - let(:expected_results) do + let(:sort_param) { } + let(:first_param) { 2 } + + let(:all_records) do if board_parent.multiple_issue_boards_available? boards.map { |board| global_id_of(board) } else 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 index af4c9286e7c..367c6d4fa3a 100644 --- 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 @@ -17,9 +17,11 @@ RSpec.shared_examples 'group and project packages query' do let(:package_names) { graphql_data_at(resource_type, :packages, :nodes, :name) } let(:target_shas) { graphql_data_at(resource_type, :packages, :nodes, :metadata, :target_sha) } let(:packages) { graphql_data_at(resource_type, :packages, :nodes) } + let(:packages_count) { graphql_data_at(resource_type, :packages, :count) } let(:fields) do <<~QUERY + count nodes { #{all_graphql_fields_for('packages'.classify, excluded: ['project'])} metadata { #{query_graphql_fragment('ComposerMetadata')} } @@ -55,6 +57,10 @@ RSpec.shared_examples 'group and project packages query' do it 'deals with metadata' do expect(target_shas).to contain_exactly(composer_metadatum.target_sha) end + + it 'returns the count of the packages' do + expect(packages_count).to eq(4) + end end context 'when the user does not have access to the resource' do @@ -95,7 +101,7 @@ RSpec.shared_examples 'group and project packages query' do it_behaves_like 'sorted paginated query' do let(:sort_param) { order } let(:first_param) { 4 } - let(:expected_results) { ascending_packages } + let(:all_records) { ascending_packages } end end end @@ -105,7 +111,7 @@ RSpec.shared_examples 'group and project packages query' do it_behaves_like 'sorted paginated query' do let(:sort_param) { order } let(:first_param) { 4 } - let(:expected_results) { ascending_packages.reverse } + let(:all_records) { ascending_packages.reverse } end end end diff --git a/spec/support/shared_examples/requests/api/issuable_search_shared_examples.rb b/spec/support/shared_examples/requests/api/issuable_search_shared_examples.rb new file mode 100644 index 00000000000..9f67bd69560 --- /dev/null +++ b/spec/support/shared_examples/requests/api/issuable_search_shared_examples.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'issuable anonymous search' do + context 'with anonymous user' do + context 'with disable_anonymous_search disabled' do + before do + stub_feature_flags(disable_anonymous_search: false) + end + + it 'returns issuables matching given search string for title' do + get api(url), params: { scope: 'all', search: issuable.title } + + expect_paginated_array_response(result) + end + + it 'returns issuables matching given search string for description' do + get api(url), params: { scope: 'all', search: issuable.description } + + expect_paginated_array_response(result) + end + end + + context 'with disable_anonymous_search enabled' do + before do + stub_feature_flags(disable_anonymous_search: true) + end + + it "returns 422 error" do + get api(url), params: { scope: 'all', search: issuable.title } + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response['message']).to eq('User must be authenticated to use search') + end + 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 cb06c9fa596..3e9c4a5eb68 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 @@ -15,6 +15,22 @@ RSpec.shared_examples 'storing arguments in the application context' do end end +# For the API we need a slightly different approach as `client_id` is +# calculated in API logging code. +RSpec.shared_examples 'storing arguments in the application context for the API' do + it 'places the expected params in the application context' do + expect(::API::API::LOG_FORMATTER).to receive(:call) do + expect(Gitlab::ApplicationContext.current).to include(log_hash(expected_params)) + end + + subject + end + + def log_hash(hash) + 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 diff --git a/spec/support/shared_examples/requests/rack_attack_shared_examples.rb b/spec/support/shared_examples/requests/rack_attack_shared_examples.rb index 2a19ff6f590..b294467d482 100644 --- a/spec/support/shared_examples/requests/rack_attack_shared_examples.rb +++ b/spec/support/shared_examples/requests/rack_attack_shared_examples.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # # Requires let variables: -# * throttle_setting_prefix: "throttle_authenticated_api", "throttle_authenticated_web", "throttle_protected_paths", "throttle_authenticated_packages_api", "throttle_authenticated_git_lfs", "throttle_authenticated_files_api" +# * throttle_setting_prefix: "throttle_authenticated_api", "throttle_authenticated_web", "throttle_protected_paths", "throttle_authenticated_packages_api", "throttle_authenticated_git_lfs", "throttle_authenticated_files_api", "throttle_authenticated_deprecated_api" # * request_method # * request_args # * other_user_request_args @@ -16,7 +16,8 @@ RSpec.shared_examples 'rate-limited token-authenticated requests' do "throttle_authenticated_web" => "throttle_authenticated_web", "throttle_authenticated_packages_api" => "throttle_authenticated_packages_api", "throttle_authenticated_git_lfs" => "throttle_authenticated_git_lfs", - "throttle_authenticated_files_api" => "throttle_authenticated_files_api" + "throttle_authenticated_files_api" => "throttle_authenticated_files_api", + "throttle_authenticated_deprecated_api" => "throttle_authenticated_deprecated_api" } end diff --git a/spec/support/shared_examples/services/dependency_proxy_settings_shared_examples.rb b/spec/support/shared_examples/services/dependency_proxy_settings_shared_examples.rb new file mode 100644 index 00000000000..2c1dc2da560 --- /dev/null +++ b/spec/support/shared_examples/services/dependency_proxy_settings_shared_examples.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'updating the dependency proxy group settings attributes' do |from: {}, to: {}| + it 'updates the dependency proxy settings' do + expect { subject } + .to change { group_settings.reload.enabled }.from(from[:enabled]).to(to[:enabled]) + end +end diff --git a/spec/support/shared_examples/workers/concerns/dependency_proxy/cleanup_worker_shared_examples.rb b/spec/support/shared_examples/workers/concerns/dependency_proxy/cleanup_worker_shared_examples.rb new file mode 100644 index 00000000000..c9014ad549c --- /dev/null +++ b/spec/support/shared_examples/workers/concerns/dependency_proxy/cleanup_worker_shared_examples.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'dependency_proxy_cleanup_worker' do + let_it_be(:group) { create(:group) } + + let(:worker) { described_class.new } + + describe '#perform_work' do + subject(:perform_work) { worker.perform_work } + + context 'with no work to do' do + it { is_expected.to be_nil } + end + + context 'with work to do' do + let_it_be(:artifact1) { create(factory_type, :expired, group: group) } + let_it_be(:artifact2) { create(factory_type, :expired, group: group, updated_at: 6.months.ago, created_at: 2.years.ago) } + let_it_be_with_reload(:artifact3) { create(factory_type, :expired, group: group, updated_at: 1.year.ago, created_at: 1.year.ago) } + let_it_be(:artifact4) { create(factory_type, group: group, updated_at: 2.years.ago, created_at: 2.years.ago) } + + it 'deletes the oldest expired artifact based on updated_at', :aggregate_failures do + expect(worker).to receive(:log_extra_metadata_on_done).with("#{factory_type}_id".to_sym, artifact3.id) + expect(worker).to receive(:log_extra_metadata_on_done).with(:group_id, group.id) + + expect { perform_work }.to change { artifact1.class.count }.by(-1) + end + end + end + + describe '#max_running_jobs' do + let(:capacity) { 5 } + + subject { worker.max_running_jobs } + + before do + stub_application_setting(dependency_proxy_ttl_group_policy_worker_capacity: capacity) + end + + it { is_expected.to eq(capacity) } + end + + describe '#remaining_work_count' do + let_it_be(:expired_artifacts) do + (1..3).map do |_| + create(factory_type, :expired, group: group) + end + end + + subject { worker.remaining_work_count } + + it { is_expected.to eq(3) } + end +end diff --git a/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb b/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb index 1b09b5fe613..d6e96ef37d6 100644 --- a/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb +++ b/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb @@ -1,11 +1,14 @@ # frozen_string_literal: true -# Expects `subject` to be a job/worker instance +# Expects `subject` to be a job/worker instance and +# `job_args` to be arguments to #perform if it takes arguments RSpec.shared_examples 'reenqueuer' do before do allow(subject).to receive(:sleep) # faster tests end + let(:subject_perform) { defined?(job_args) ? subject.perform(job_args) : subject.perform } + it 'implements lease_timeout' do expect(subject.lease_timeout).to be_a(ActiveSupport::Duration) end @@ -18,12 +21,13 @@ RSpec.shared_examples 'reenqueuer' do it 'tries to obtain a lease' do expect_to_obtain_exclusive_lease(subject.lease_key) - subject.perform + subject_perform end end end -# Expects `subject` to be a job/worker instance +# Expects `subject` to be a job/worker instance and +# `job_args` to be arguments to #perform if it takes arguments RSpec.shared_examples '#perform is rate limited to 1 call per' do |minimum_duration| before do # Allow Timecop freeze and travel without the block form @@ -38,13 +42,15 @@ RSpec.shared_examples '#perform is rate limited to 1 call per' do |minimum_durat Timecop.safe_mode = true end + let(:subject_perform) { defined?(job_args) ? subject.perform(job_args) : subject.perform } + context 'when the work finishes in 0 seconds' do let(:actual_duration) { 0 } it 'sleeps exactly the minimum duration' do expect(subject).to receive(:sleep).with(a_value_within(0.01).of(minimum_duration)) - subject.perform + subject_perform end end @@ -54,7 +60,7 @@ RSpec.shared_examples '#perform is rate limited to 1 call per' do |minimum_durat it 'sleeps 90% of minimum duration' do expect(subject).to receive(:sleep).with(a_value_within(0.01).of(0.9 * minimum_duration)) - subject.perform + subject_perform end end @@ -64,7 +70,7 @@ RSpec.shared_examples '#perform is rate limited to 1 call per' do |minimum_durat it 'sleeps 10% of minimum duration' do expect(subject).to receive(:sleep).with(a_value_within(0.01).of(0.1 * minimum_duration)) - subject.perform + subject_perform end end @@ -74,7 +80,7 @@ RSpec.shared_examples '#perform is rate limited to 1 call per' do |minimum_durat it 'does not sleep' do expect(subject).not_to receive(:sleep) - subject.perform + subject_perform end end @@ -84,7 +90,7 @@ RSpec.shared_examples '#perform is rate limited to 1 call per' do |minimum_durat it 'does not sleep' do expect(subject).not_to receive(:sleep) - subject.perform + subject_perform end end @@ -94,7 +100,7 @@ RSpec.shared_examples '#perform is rate limited to 1 call per' do |minimum_durat it 'does not sleep' do expect(subject).not_to receive(:sleep) - subject.perform + subject_perform end end diff --git a/spec/support_specs/database/multiple_databases_spec.rb b/spec/support_specs/database/multiple_databases_spec.rb new file mode 100644 index 00000000000..6ad15fd6594 --- /dev/null +++ b/spec/support_specs/database/multiple_databases_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Database::MultipleDatabases' do + describe '.with_reestablished_active_record_base' do + context 'when doing establish_connection' do + context 'on ActiveRecord::Base' do + it 'raises exception' do + expect { ActiveRecord::Base.establish_connection(:main) }.to raise_error /Cannot re-establish/ + end + + context 'when using with_reestablished_active_record_base' do + it 'does not raise exception' do + with_reestablished_active_record_base do + expect { ActiveRecord::Base.establish_connection(:main) }.not_to raise_error + end + end + end + end + + context 'on Ci::CiDatabaseRecord' do + before do + skip_if_multiple_databases_not_setup + end + + it 'raises exception' do + expect { Ci::CiDatabaseRecord.establish_connection(:ci) }.to raise_error /Cannot re-establish/ + end + + context 'when using with_reestablished_active_record_base' do + it 'does not raise exception' do + with_reestablished_active_record_base do + expect { Ci::CiDatabaseRecord.establish_connection(:main) }.not_to raise_error + end + end + end + end + end + + context 'when trying to access connection' do + context 'when reconnect is true' do + it 'does not raise exception' do + with_reestablished_active_record_base(reconnect: true) do + expect { ActiveRecord::Base.connection.execute("SELECT 1") }.not_to raise_error # rubocop:disable Database/MultipleDatabases + end + end + end + + context 'when reconnect is false' do + it 'does raise exception' do + with_reestablished_active_record_base(reconnect: false) do + expect { ActiveRecord::Base.connection.execute("SELECT 1") }.to raise_error(ActiveRecord::ConnectionNotEstablished) # rubocop:disable Database/MultipleDatabases + end + end + end + end + end +end diff --git a/spec/support_specs/database/prevent_cross_joins_spec.rb b/spec/support_specs/database/prevent_cross_joins_spec.rb index e9a95fe77a5..0fbcd190c2c 100644 --- a/spec/support_specs/database/prevent_cross_joins_spec.rb +++ b/spec/support_specs/database/prevent_cross_joins_spec.rb @@ -22,6 +22,12 @@ RSpec.describe Database::PreventCrossJoins do described_class::CrossJoinAcrossUnsupportedTablesError) end + context 'when annotation is used' do + it 'does not raise exception' do + expect { main_and_ci_allowed_via_annotate }.not_to raise_error + end + end + context 'when allow_cross_joins_across_databases is used' do it 'does not raise exception' do expect { main_and_ci_query_allowlisted }.not_to raise_error @@ -52,6 +58,12 @@ RSpec.describe Database::PreventCrossJoins do end end + def main_and_ci_allowed_via_annotate + main_and_ci_query do |relation| + relation.allow_cross_joins_across_databases(url: 'http://issue-url') + end + end + def main_only_query Issue.joins(:project).last end @@ -61,6 +73,8 @@ RSpec.describe Database::PreventCrossJoins do end def main_and_ci_query - Ci::Build.joins(:project).last + relation = Ci::Build.joins(:project) + relation = yield(relation) if block_given? + relation.last end end diff --git a/spec/tasks/gitlab/db_rake_spec.rb b/spec/tasks/gitlab/db_rake_spec.rb index 91cd09fc6e6..ad4ada9a9f1 100644 --- a/spec/tasks/gitlab/db_rake_spec.rb +++ b/spec/tasks/gitlab/db_rake_spec.rb @@ -293,53 +293,24 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do end describe '#migrate_with_instrumentation' do - subject { run_rake_task('gitlab:db:migration_testing') } + describe '#up' do + subject { run_rake_task('gitlab:db:migration_testing:up') } - let(:ctx) { double('ctx', migrations: all_migrations, schema_migration: double, get_all_versions: existing_versions) } - let(:instrumentation) { instance_double(Gitlab::Database::Migrations::Instrumentation, observations: observations) } - let(:existing_versions) { [1] } - let(:all_migrations) { [double('migration1', version: 1, name: 'test'), pending_migration] } - let(:pending_migration) { double('migration2', version: 2, name: 'test') } - let(:filename) { Gitlab::Database::Migrations::Instrumentation::STATS_FILENAME } - let(:result_dir) { Dir.mktmpdir } - let(:observations) { %w[some data] } + it 'delegates to the migration runner' do + expect(::Gitlab::Database::Migrations::Runner).to receive_message_chain(:up, :run) - before do - allow(ActiveRecord::Base.connection).to receive(:migration_context).and_return(ctx) - allow(Gitlab::Database::Migrations::Instrumentation).to receive(:new).and_return(instrumentation) - allow(ActiveRecord::Migrator).to receive_message_chain('new.run').with(any_args).with(no_args) - - allow(instrumentation).to receive(:observe).and_yield - - stub_const('Gitlab::Database::Migrations::Instrumentation::RESULT_DIR', result_dir) - end - - after do - FileUtils.rm_rf(result_dir) - end - - it 'creates result directory when one does not exist' do - FileUtils.rm_rf(result_dir) - - expect { subject }.to change { Dir.exist?(result_dir) }.from(false).to(true) - end - - it 'instruments the pending migration' do - expect(instrumentation).to receive(:observe).with(version: 2, name: 'test').and_yield - - subject + subject + end end - it 'executes the pending migration' do - expect(ActiveRecord::Migrator).to receive_message_chain('new.run').with(:up, ctx.migrations, ctx.schema_migration, pending_migration.version).with(no_args) + describe '#down' do + subject { run_rake_task('gitlab:db:migration_testing:down') } - subject - end - - it 'writes observations out to JSON file' do - subject + it 'delegates to the migration runner' do + expect(::Gitlab::Database::Migrations::Runner).to receive_message_chain(:down, :run) - expect(File.read(File.join(result_dir, filename))).to eq(observations.to_json) + subject + end end end diff --git a/spec/tasks/gitlab/packages/composer_rake_spec.rb b/spec/tasks/gitlab/packages/composer_rake_spec.rb deleted file mode 100644 index f4f43bf77d8..00000000000 --- a/spec/tasks/gitlab/packages/composer_rake_spec.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require 'rake_helper' - -RSpec.describe 'gitlab:packages:build_composer_cache namespace rake task', :silence_stdout 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/product_intelligence_spec.rb b/spec/tooling/danger/product_intelligence_spec.rb index 4ab911b6590..5fd44ef5de0 100644 --- a/spec/tooling/danger/product_intelligence_spec.rb +++ b/spec/tooling/danger/product_intelligence_spec.rb @@ -59,6 +59,14 @@ RSpec.describe Tooling::Danger::ProductIntelligence do it { is_expected.to be_empty } end + + context 'with growth experiment label' do + before do + allow(fake_helper).to receive(:mr_has_labels?).with('growth experiment').and_return(true) + end + + it { is_expected.to be_empty } + end end describe '#matching_changed_files' do @@ -74,7 +82,7 @@ RSpec.describe Tooling::Danger::ProductIntelligence do context 'with snowplow files changed' do context 'when vue file changed' do - let(:changed_lines) { ['+data-track-event'] } + let(:changed_lines) { ['+data-track-action'] } it { is_expected.to match_array(['components/welcome.vue']) } end diff --git a/spec/tooling/danger/project_helper_spec.rb b/spec/tooling/danger/project_helper_spec.rb index c7715eb43fc..5edd9e54cc5 100644 --- a/spec/tooling/danger/project_helper_spec.rb +++ b/spec/tooling/danger/project_helper_spec.rb @@ -93,6 +93,9 @@ RSpec.describe Tooling::Danger::ProjectHelper do 'ee/spec/foo' | [:backend] 'ee/spec/foo/bar' | [:backend] + 'spec/migrations/foo' | [:database] + 'ee/spec/migrations/foo' | [:database] + 'spec/features/foo' | [:test] 'ee/spec/features/foo' | [:test] 'spec/support/shared_examples/features/foo' | [:test] @@ -277,4 +280,17 @@ RSpec.describe Tooling::Danger::ProjectHelper do is_expected.to eq('gitlab-foss') end end + + describe '#file_lines' do + let(:filename) { 'spec/foo_spec.rb' } + let(:file_spy) { spy } + + it 'returns the chomped file lines' do + expect(project_helper).to receive(:read_file).with(filename).and_return(file_spy) + + project_helper.file_lines(filename) + + expect(file_spy).to have_received(:lines).with(chomp: true) + end + end end diff --git a/spec/tooling/danger/specs_spec.rb b/spec/tooling/danger/specs_spec.rb new file mode 100644 index 00000000000..a5978020c9d --- /dev/null +++ b/spec/tooling/danger/specs_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'rspec-parameterized' +require 'gitlab-dangerfiles' +require 'danger' +require 'danger/plugins/helper' +require 'gitlab/dangerfiles/spec_helper' + +require_relative '../../../tooling/danger/specs' +require_relative '../../../tooling/danger/project_helper' + +RSpec.describe Tooling::Danger::Specs do + include_context "with dangerfile" + + 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(:file_lines) do + [ + " describe 'foo' do", + " expect(foo).to match(['bar'])", + " end", + " expect(foo).to match(['bar'])", # same line as line 1 above, we expect two different suggestions + " ", + " expect(foo).to match ['bar']", + " expect(foo).to eq(['bar'])", + " expect(foo).to eq ['bar']", + " expect(foo).to(match(['bar']))", + " expect(foo).to(eq(['bar']))", + " foo.eq(['bar'])" + ] + end + + let(:matching_lines) do + [ + "+ expect(foo).to match(['bar'])", + "+ expect(foo).to match(['bar'])", + "+ expect(foo).to match ['bar']", + "+ expect(foo).to eq(['bar'])", + "+ expect(foo).to eq ['bar']", + "+ expect(foo).to(match(['bar']))", + "+ expect(foo).to(eq(['bar']))" + ] + end + + subject(:specs) { fake_danger.new(helper: fake_helper) } + + before do + allow(specs).to receive(:project_helper).and_return(fake_project_helper) + end + + describe '#add_suggestions_for_match_with_array' do + let(:filename) { 'spec/foo_spec.rb' } + + before do + expect(specs).to receive(:added_line_matching_match_with_array).and_return(matching_lines) + allow(specs.project_helper).to receive(:file_lines).and_return(file_lines) + end + + it 'adds suggestions at the correct lines' do + expect(specs).to receive(:markdown).with(format(described_class::SUGGEST_MR_COMMENT, suggested_line: " expect(foo).to match_array(['bar'])"), file: filename, line: 2) + expect(specs).to receive(:markdown).with(format(described_class::SUGGEST_MR_COMMENT, suggested_line: " expect(foo).to match_array(['bar'])"), file: filename, line: 4) + expect(specs).to receive(:markdown).with(format(described_class::SUGGEST_MR_COMMENT, suggested_line: " expect(foo).to match_array ['bar']"), file: filename, line: 6) + expect(specs).to receive(:markdown).with(format(described_class::SUGGEST_MR_COMMENT, suggested_line: " expect(foo).to match_array(['bar'])"), file: filename, line: 7) + expect(specs).to receive(:markdown).with(format(described_class::SUGGEST_MR_COMMENT, suggested_line: " expect(foo).to match_array ['bar']"), file: filename, line: 8) + expect(specs).to receive(:markdown).with(format(described_class::SUGGEST_MR_COMMENT, suggested_line: " expect(foo).to(match_array(['bar']))"), file: filename, line: 9) + expect(specs).to receive(:markdown).with(format(described_class::SUGGEST_MR_COMMENT, suggested_line: " expect(foo).to(match_array(['bar']))"), file: filename, line: 10) + + specs.add_suggestions_for_match_with_array(filename) + end + end + + describe '#changed_specs_files' do + let(:base_expected_files) { %w[spec/foo_spec.rb ee/spec/foo_spec.rb spec/bar_spec.rb ee/spec/bar_spec.rb spec/zab_spec.rb ee/spec/zab_spec.rb] } + + before do + all_changed_files = %w[ + app/workers/a.rb + app/workers/b.rb + app/workers/e.rb + spec/foo_spec.rb + ee/spec/foo_spec.rb + spec/bar_spec.rb + ee/spec/bar_spec.rb + spec/zab_spec.rb + ee/spec/zab_spec.rb + ] + + allow(specs.helper).to receive(:all_changed_files).and_return(all_changed_files) + end + + it 'returns added, modified, and renamed_after files by default' do + expect(specs.changed_specs_files).to match_array(base_expected_files) + end + + context 'with include_ee: :exclude' do + it 'returns spec files without EE-specific files' do + expect(specs.changed_specs_files(ee: :exclude)).not_to include(%w[ee/spec/foo_spec.rb ee/spec/bar_spec.rb ee/spec/zab_spec.rb]) + end + end + + context 'with include_ee: :only' do + it 'returns EE-specific spec files only' do + expect(specs.changed_specs_files(ee: :only)).to match_array(%w[ee/spec/foo_spec.rb ee/spec/bar_spec.rb ee/spec/zab_spec.rb]) + end + end + end + + describe '#added_line_matching_match_with_array' do + let(:filename) { 'spec/foo_spec.rb' } + let(:changed_lines) do + [ + " expect(foo).to match(['bar'])", + " expect(foo).to match(['bar'])", + " expect(foo).to match ['bar']", + " expect(foo).to eq(['bar'])", + " expect(foo).to eq ['bar']", + "- expect(foo).to match(['bar'])", + "- expect(foo).to match(['bar'])", + "- expect(foo).to match ['bar']", + "- expect(foo).to eq(['bar'])", + "- expect(foo).to eq ['bar']" + ] + matching_lines + end + + before do + allow(specs.helper).to receive(:changed_lines).with(filename).and_return(changed_lines) + end + + it 'returns added, modified, and renamed_after files by default' do + expect(specs.added_line_matching_match_with_array(filename)).to match_array(matching_lines) + end + end +end diff --git a/spec/tooling/quality/test_level_spec.rb b/spec/tooling/quality/test_level_spec.rb index 89abe337347..0623a67a60e 100644 --- a/spec/tooling/quality/test_level_spec.rb +++ b/spec/tooling/quality/test_level_spec.rb @@ -63,7 +63,14 @@ RSpec.describe Quality::TestLevel do context 'with a prefix' do it 'returns a pattern' do expect(described_class.new('ee/').pattern(:system)) - .to eq("ee/spec/{features}{,/**/}*_spec.rb") + .to eq("{ee/}spec/{features}{,/**/}*_spec.rb") + end + end + + context 'with several prefixes' do + it 'returns a pattern' do + expect(described_class.new(['', 'ee/', 'jh/']).pattern(:system)) + .to eq("{,ee/,jh/}spec/{features}{,/**/}*_spec.rb") end end @@ -138,7 +145,14 @@ RSpec.describe Quality::TestLevel do context 'with a prefix' do it 'returns a regexp' do expect(described_class.new('ee/').regexp(:system)) - .to eq(%r{ee/spec/(features)}) + .to eq(%r{(ee/)spec/(features)}) + end + end + + context 'with several prefixes' do + it 'returns a regexp' do + expect(described_class.new(['', 'ee/', 'jh/']).regexp(:system)) + .to eq(%r{(|ee/|jh/)spec/(features)}) end end diff --git a/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb index 2c37565328a..d4e97d96dfd 100644 --- a/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb +++ b/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb @@ -58,6 +58,15 @@ RSpec.describe 'layouts/nav/sidebar/_admin' do it_behaves_like 'page has active sub tab', 'Users' end + context 'on topics' do + before do + allow(controller).to receive(:controller_name).and_return('admin/topics') + end + + it_behaves_like 'page has active tab', 'Overview' + it_behaves_like 'page has active sub tab', 'Topics' + end + context 'on messages' do before do allow(controller).to receive(:controller_name).and_return('broadcast_messages') 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 adfe1cee6d6..20c5d9992be 100644 --- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb +++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb @@ -580,6 +580,23 @@ RSpec.describe 'layouts/nav/sidebar/_project' do end end end + + describe 'Google Cloud' do + it 'has a link to the google cloud page' do + render + expect(rendered).to have_link('Google Cloud', href: project_google_cloud_index_path(project)) + end + + describe 'when the user does not have access' do + let(:user) { nil } + + it 'does not have a link to the google cloud page' do + render + + expect(rendered).not_to have_link('Google Cloud') + end + end + end end describe 'Packages and Registries' do diff --git a/spec/views/projects/branches/index.html.haml_spec.rb b/spec/views/projects/branches/index.html.haml_spec.rb new file mode 100644 index 00000000000..9954d9ecaec --- /dev/null +++ b/spec/views/projects/branches/index.html.haml_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'projects/branches/index.html.haml' do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:repository) { project.repository } + + let(:branches) { repository.branches } + let(:active_branch) { branches.find { |b| b.name == 'master' } } + let(:stale_branch) { branches.find { |b| b.name == 'feature' } } + + before do + assign(:project, project) + assign(:repository, repository) + assign(:mode, 'overview') + assign(:active_branches, [active_branch]) + assign(:stale_branches, [stale_branch]) + assign(:overview_max_branches, 5) + assign(:branch_pipeline_statuses, {}) + assign(:refs_pipelines, {}) + end + + it 'renders list of active and stale branches' do + content = render + + expect(content).to include(active_branch.name) + expect(content).to include(stale_branch.name) + end + + context 'when Gitaly is unavailable' do + it 'renders an error' do + assign(:gitaly_unavailable, true) + + content = render + + expect(content).to include('Unable to load branches') + expect(content).to include( + 'The git server, Gitaly, is not available at this time. Please contact your administrator.' + ) + end + end +end diff --git a/spec/views/projects/commits/_commit.html.haml_spec.rb b/spec/views/projects/commits/_commit.html.haml_spec.rb index abbb3a168c3..ed93240abc1 100644 --- a/spec/views/projects/commits/_commit.html.haml_spec.rb +++ b/spec/views/projects/commits/_commit.html.haml_spec.rb @@ -11,6 +11,24 @@ RSpec.describe 'projects/commits/_commit.html.haml' do allow(view).to receive(:current_application_settings).and_return(Gitlab::CurrentSettings.current_application_settings) end + context 'with different committer' do + let(:ref) { 'master' } + let(:committer) { create(:user) } + + it 'renders committed by user' do + allow(commit).to receive(:different_committer?).and_return(true) + allow(commit).to receive(:committer).and_return(committer) + + render partial: template, locals: { + project: project, + ref: ref, + commit: commit + } + + expect(rendered).to have_text("#{committer.name} committed") + end + end + context 'with a signed commit' do let(:ref) { GpgHelpers::SIGNED_COMMIT_SHA } diff --git a/spec/views/projects/services/edit.html.haml_spec.rb b/spec/views/projects/services/edit.html.haml_spec.rb index a5460adbd2c..372ccf82a68 100644 --- a/spec/views/projects/services/edit.html.haml_spec.rb +++ b/spec/views/projects/services/edit.html.haml_spec.rb @@ -14,7 +14,7 @@ RSpec.describe 'projects/services/edit' do it do render - expect(rendered).not_to have_text('Recent Deliveries') + expect(rendered).not_to have_text('Recent events') end context 'integration using WebHooks' do @@ -25,7 +25,7 @@ RSpec.describe 'projects/services/edit' do it do render - expect(rendered).to have_text('Recent Deliveries') + expect(rendered).to have_text('Recent events') end end end diff --git a/spec/views/projects/tags/index.html.haml_spec.rb b/spec/views/projects/tags/index.html.haml_spec.rb index 2702ab9e2a9..ebd526284d1 100644 --- a/spec/views/projects/tags/index.html.haml_spec.rb +++ b/spec/views/projects/tags/index.html.haml_spec.rb @@ -3,10 +3,11 @@ require 'spec_helper' RSpec.describe 'projects/tags/index.html.haml' do - let(:project) { create(:project, :repository) } - let(:tags) { TagsFinder.new(project.repository, {}).execute } - let(:git_tag) { project.repository.tags.last } - let(:release) { create(:release, project: project, sha: git_tag.target_commit.sha) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:tags) { project.repository.tags } + let_it_be(:git_tag) { project.repository.tags.last } + let_it_be(:release) { create(:release, project: project, sha: git_tag.target_commit.sha) } + let(:pipeline) { create(:ci_pipeline, :success, project: project, ref: git_tag.name, sha: release.sha) } before do @@ -86,4 +87,17 @@ RSpec.describe 'projects/tags/index.html.haml' do expect(page.all('.tags .content-list li')).not_to have_css 'svg.s24' end end + + context 'when Gitaly is unavailable' do + it 'renders an error' do + assign(:tags_loading_error, GRPC::Unavailable.new) + + content = render + + expect(content).to include("Unable to load tags") + expect(content).to include( + "The git server, Gitaly, is not available at this time. Please contact your administrator." + ) + end + end end diff --git a/spec/workers/authorized_project_update/project_recalculate_per_user_worker_spec.rb b/spec/workers/authorized_project_update/project_recalculate_per_user_worker_spec.rb new file mode 100644 index 00000000000..57a0726000f --- /dev/null +++ b/spec/workers/authorized_project_update/project_recalculate_per_user_worker_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker do + include ExclusiveLeaseHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + + subject(:worker) { described_class.new } + + include_examples 'an idempotent worker' do + let(:job_args) { [project.id, user.id] } + + it 'does not change authorizations when run twice' do + project.add_developer(user) + + user.project_authorizations.delete_all + + expect { worker.perform(project.id, user.id) }.to change { project.project_authorizations.reload.size }.by(1) + expect { worker.perform(project.id, user.id) }.not_to change { project.project_authorizations.reload.size } + end + end + + describe '#perform' do + it 'does not fail if the project does not exist' do + expect do + worker.perform(non_existing_record_id, user.id) + end.not_to raise_error + end + + it 'does not fail if the user does not exist' do + expect do + worker.perform(project.id, non_existing_record_id) + end.not_to raise_error + end + + it 'calls AuthorizedProjectUpdate::ProjectRecalculatePerUserService' do + expect_next_instance_of(AuthorizedProjectUpdate::ProjectRecalculatePerUserService, project, user) do |service| + expect(service).to receive(:execute) + end + + worker.perform(project.id, user.id) + end + + context 'exclusive lease' do + let(:lock_key) { "#{described_class.superclass.name.underscore}/projects/#{project.id}" } + let(:timeout) { 10.seconds } + + context 'when exclusive lease has not been taken' do + it 'obtains a new exclusive lease' do + expect_to_obtain_exclusive_lease(lock_key, timeout: timeout) + + worker.perform(project.id, user.id) + end + end + + context 'when exclusive lease has already been taken' do + before do + stub_exclusive_lease_taken(lock_key, timeout: timeout) + end + + it 'raises an error' do + expect { worker.perform(project.id, user.id) }.to raise_error(Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError) + end + end + end + end +end diff --git a/spec/workers/authorized_project_update/user_refresh_from_replica_worker_spec.rb b/spec/workers/authorized_project_update/user_refresh_from_replica_worker_spec.rb index 0da58343773..da4b726c0b5 100644 --- a/spec/workers/authorized_project_update/user_refresh_from_replica_worker_spec.rb +++ b/spec/workers/authorized_project_update/user_refresh_from_replica_worker_spec.rb @@ -44,7 +44,7 @@ RSpec.describe AuthorizedProjectUpdate::UserRefreshFromReplicaWorker do end end - context 'with load balancing enabled', :db_load_balancing do + context 'with load balancing enabled' do it 'reads from the replica database' do expect(Gitlab::Database::LoadBalancing::Session.current).to receive(:use_replicas_for_read_queries).and_call_original diff --git a/spec/workers/build_finished_worker_spec.rb b/spec/workers/build_finished_worker_spec.rb index 4e34d2348d6..2ca7837066b 100644 --- a/spec/workers/build_finished_worker_spec.rb +++ b/spec/workers/build_finished_worker_spec.rb @@ -3,7 +3,9 @@ require 'spec_helper' RSpec.describe BuildFinishedWorker do - subject { described_class.new.perform(build.id) } + let(:worker) { described_class.new } + + subject { worker.perform(build.id) } describe '#perform' do context 'when build exists' do @@ -63,6 +65,30 @@ RSpec.describe BuildFinishedWorker do subject end end + + context 'when project is deleted' do + before do + allow(build).to receive(:project).and_return(nil) + end + + it 'does no processing' do + expect(worker).not_to receive(:process_build) + + subject + end + end + + context 'when project is pending_delete' do + before do + build.project.update_attribute(:pending_delete, true) + end + + it 'does no processing' do + expect(worker).not_to receive(:process_build) + + subject + end + end end context 'when build does not exist' do diff --git a/spec/workers/bulk_import_worker_spec.rb b/spec/workers/bulk_import_worker_spec.rb index b67c5c62f76..12e29573156 100644 --- a/spec/workers/bulk_import_worker_spec.rb +++ b/spec/workers/bulk_import_worker_spec.rb @@ -84,7 +84,7 @@ RSpec.describe BulkImportWorker do expect { subject.perform(bulk_import.id) } .to change(BulkImports::Tracker, :count) - .by(BulkImports::Groups::Stage.pipelines.size * 2) + .by(BulkImports::Groups::Stage.new(bulk_import).pipelines.size * 2) expect(entity_1.trackers).not_to be_empty expect(entity_2.trackers).not_to be_empty @@ -111,10 +111,10 @@ RSpec.describe BulkImportWorker do end context 'when there are project entities to process' do - it 'does not enqueue ExportRequestWorker' do + it 'enqueues ExportRequestWorker' do create(:bulk_import_entity, :created, :project_entity, bulk_import: bulk_import) - expect(BulkImports::ExportRequestWorker).not_to receive(:perform_async) + expect(BulkImports::ExportRequestWorker).to receive(:perform_async).once subject.perform(bulk_import.id) end diff --git a/spec/workers/bulk_imports/export_request_worker_spec.rb b/spec/workers/bulk_imports/export_request_worker_spec.rb index cb280c6d263..f838bff528c 100644 --- a/spec/workers/bulk_imports/export_request_worker_spec.rb +++ b/spec/workers/bulk_imports/export_request_worker_spec.rb @@ -5,7 +5,6 @@ require 'spec_helper' RSpec.describe BulkImports::ExportRequestWorker do let_it_be(:bulk_import) { create(:bulk_import) } let_it_be(:config) { create(:bulk_import_configuration, bulk_import: bulk_import) } - let_it_be(:entity) { create(:bulk_import_entity, source_full_path: 'foo/bar', bulk_import: bulk_import) } let_it_be(:version_url) { 'https://gitlab.example/api/v4/version' } let(:response_double) { double(code: 200, success?: true, parsed_response: {}) } @@ -20,16 +19,30 @@ RSpec.describe BulkImports::ExportRequestWorker do allow(Gitlab::HTTP).to receive(:post).and_return(response_double) end - include_examples 'an idempotent worker' do - it 'requests relations export' do - expected = "/groups/foo%2Fbar/export_relations" + shared_examples 'requests relations export for api resource' do + include_examples 'an idempotent worker' do + it 'requests relations export' do + expect_next_instance_of(BulkImports::Clients::HTTP) do |client| + expect(client).to receive(:post).with(expected).twice + end - expect_next_instance_of(BulkImports::Clients::HTTP) do |client| - expect(client).to receive(:post).with(expected).twice + perform_multiple(job_args) end - - perform_multiple(job_args) end end + + context 'when entity is group' do + let(:entity) { create(:bulk_import_entity, :group_entity, source_full_path: 'foo/bar', bulk_import: bulk_import) } + let(:expected) { '/groups/foo%2Fbar/export_relations'} + + include_examples 'requests relations export for api resource' + end + + context 'when entity is project' do + let(:entity) { create(:bulk_import_entity, :project_entity, source_full_path: 'foo/bar', bulk_import: bulk_import) } + let(:expected) { '/projects/foo%2Fbar/export_relations' } + + include_examples 'requests relations export for api resource' + end end end diff --git a/spec/workers/bulk_imports/pipeline_worker_spec.rb b/spec/workers/bulk_imports/pipeline_worker_spec.rb index 56f28654ac5..c902d1f2034 100644 --- a/spec/workers/bulk_imports/pipeline_worker_spec.rb +++ b/spec/workers/bulk_imports/pipeline_worker_spec.rb @@ -22,47 +22,65 @@ RSpec.describe BulkImports::PipelineWorker do before do stub_const('FakePipeline', pipeline_class) - allow(BulkImports::Groups::Stage) - .to receive(:pipelines) - .and_return([[0, pipeline_class]]) + allow_next_instance_of(BulkImports::Groups::Stage) do |instance| + allow(instance).to receive(:pipelines) + .and_return([[0, pipeline_class]]) + end end - it 'runs the given pipeline successfully' do - pipeline_tracker = create( - :bulk_import_tracker, - entity: entity, - pipeline_name: 'FakePipeline' - ) + shared_examples 'successfully runs the pipeline' do + it 'runs the given pipeline successfully' do + expect_next_instance_of(Gitlab::Import::Logger) do |logger| + expect(logger) + .to receive(:info) + .with( + worker: described_class.name, + pipeline_name: 'FakePipeline', + entity_id: entity.id + ) + end + + expect(BulkImports::EntityWorker) + .to receive(:perform_async) + .with(entity.id, pipeline_tracker.stage) - expect_next_instance_of(Gitlab::Import::Logger) do |logger| - expect(logger) - .to receive(:info) - .with( - worker: described_class.name, - pipeline_name: 'FakePipeline', - entity_id: entity.id - ) - end + expect(subject).to receive(:jid).and_return('jid') - expect(BulkImports::EntityWorker) - .to receive(:perform_async) - .with(entity.id, pipeline_tracker.stage) + subject.perform(pipeline_tracker.id, pipeline_tracker.stage, entity.id) - expect(subject).to receive(:jid).and_return('jid') + pipeline_tracker.reload - subject.perform(pipeline_tracker.id, pipeline_tracker.stage, entity.id) + expect(pipeline_tracker.status_name).to eq(:finished) + expect(pipeline_tracker.jid).to eq('jid') + end + end - pipeline_tracker.reload + it_behaves_like 'successfully runs the pipeline' do + let(:pipeline_tracker) do + create( + :bulk_import_tracker, + entity: entity, + pipeline_name: 'FakePipeline' + ) + end + end - expect(pipeline_tracker.status_name).to eq(:finished) - expect(pipeline_tracker.jid).to eq('jid') + it_behaves_like 'successfully runs the pipeline' do + let(:pipeline_tracker) do + create( + :bulk_import_tracker, + :started, + entity: entity, + pipeline_name: 'FakePipeline' + ) + end end context 'when the pipeline cannot be found' do it 'logs the error' do pipeline_tracker = create( :bulk_import_tracker, - :started, + :finished, entity: entity, pipeline_name: 'FakePipeline' ) @@ -126,6 +144,39 @@ RSpec.describe BulkImports::PipelineWorker do expect(pipeline_tracker.status_name).to eq(:failed) expect(pipeline_tracker.jid).to eq('jid') end + + context 'when it is a network error' do + it 'reenqueue on retriable network errors' do + pipeline_tracker = create( + :bulk_import_tracker, + entity: entity, + pipeline_name: 'FakePipeline' + ) + + exception = BulkImports::NetworkError.new( + response: double(code: 429, headers: {}) + ) + + expect_next_instance_of(pipeline_class) do |pipeline| + expect(pipeline) + .to receive(:run) + .and_raise(exception) + end + + expect(subject).to receive(:jid).and_return('jid') + + expect(described_class) + .to receive(:perform_in) + .with( + 60.seconds, + pipeline_tracker.id, + pipeline_tracker.stage, + pipeline_tracker.entity.id + ) + + subject.perform(pipeline_tracker.id, pipeline_tracker.stage, entity.id) + end + end end context 'when ndjson pipeline' do @@ -156,9 +207,10 @@ RSpec.describe BulkImports::PipelineWorker do before do stub_const('NdjsonPipeline', ndjson_pipeline) - allow(BulkImports::Groups::Stage) - .to receive(:pipelines) - .and_return([[0, ndjson_pipeline]]) + allow_next_instance_of(BulkImports::Groups::Stage) do |instance| + allow(instance).to receive(:pipelines) + .and_return([[0, ndjson_pipeline]]) + end end it 'runs the pipeline successfully' do diff --git a/spec/workers/ci/create_downstream_pipeline_worker_spec.rb b/spec/workers/ci/create_downstream_pipeline_worker_spec.rb new file mode 100644 index 00000000000..7a75da850d9 --- /dev/null +++ b/spec/workers/ci/create_downstream_pipeline_worker_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::CreateDownstreamPipelineWorker do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:pipeline) { create(:ci_pipeline, project: project) } + + let(:bridge) { create(:ci_bridge, user: user, pipeline: pipeline) } + + let(:service) { double('pipeline creation service') } + + describe '#perform' do + context 'when bridge exists' do + it 'calls cross project pipeline creation service' do + expect(Ci::CreateDownstreamPipelineService) + .to receive(:new) + .with(project, user) + .and_return(service) + + expect(service).to receive(:execute).with(bridge) + + described_class.new.perform(bridge.id) + end + end + + context 'when bridge does not exist' do + it 'does nothing' do + expect(Ci::CreateDownstreamPipelineService) + .not_to receive(:new) + + described_class.new.perform(non_existing_record_id) + end + end + end +end diff --git a/spec/workers/ci/stuck_builds/drop_running_worker_spec.rb b/spec/workers/ci/stuck_builds/drop_running_worker_spec.rb new file mode 100644 index 00000000000..6d3aa71fe81 --- /dev/null +++ b/spec/workers/ci/stuck_builds/drop_running_worker_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::StuckBuilds::DropRunningWorker do + include ExclusiveLeaseHelpers + + let(:worker) { described_class.new } + let(:lease_uuid) { SecureRandom.uuid } + + describe '#perform' do + subject { worker.perform } + + it_behaves_like 'an idempotent worker' + + it 'executes an instance of Ci::StuckBuilds::DropRunningService' do + expect_to_obtain_exclusive_lease(worker.lease_key, lease_uuid) + + expect_next_instance_of(Ci::StuckBuilds::DropRunningService) do |service| + expect(service).to receive(:execute).exactly(:once) + end + + expect_to_cancel_exclusive_lease(worker.lease_key, lease_uuid) + + subject + end + end +end diff --git a/spec/workers/ci/stuck_builds/drop_scheduled_worker_spec.rb b/spec/workers/ci/stuck_builds/drop_scheduled_worker_spec.rb new file mode 100644 index 00000000000..57be799d890 --- /dev/null +++ b/spec/workers/ci/stuck_builds/drop_scheduled_worker_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::StuckBuilds::DropScheduledWorker do + include ExclusiveLeaseHelpers + + let(:worker) { described_class.new } + let(:lease_uuid) { SecureRandom.uuid } + + describe '#perform' do + subject { worker.perform } + + it_behaves_like 'an idempotent worker' + + it 'executes an instance of Ci::StuckBuilds::DropScheduledService with an exclusive lease' do + expect_to_obtain_exclusive_lease(worker.lease_key, lease_uuid) + + expect_next_instance_of(Ci::StuckBuilds::DropScheduledService) do |service| + expect(service).to receive(:execute).exactly(:once) + end + + expect_to_cancel_exclusive_lease(worker.lease_key, lease_uuid) + + subject + end + end +end diff --git a/spec/workers/cleanup_container_repository_worker_spec.rb b/spec/workers/cleanup_container_repository_worker_spec.rb index 9cf8974a2a1..6ae4308bd46 100644 --- a/spec/workers/cleanup_container_repository_worker_spec.rb +++ b/spec/workers/cleanup_container_repository_worker_spec.rb @@ -17,7 +17,7 @@ RSpec.describe CleanupContainerRepositoryWorker, :clean_gitlab_redis_shared_stat it 'executes the destroy service' do expect(Projects::ContainerRepository::CleanupTagsService).to receive(:new) - .with(project, user, params.merge('container_expiration_policy' => false)) + .with(repository, user, params.merge('container_expiration_policy' => false)) .and_return(service) expect(service).to receive(:execute) @@ -49,7 +49,7 @@ RSpec.describe CleanupContainerRepositoryWorker, :clean_gitlab_redis_shared_stat expect(repository).to receive(:start_expiration_policy!).and_call_original expect(repository).to receive(:reset_expiration_policy_started_at!).and_call_original expect(Projects::ContainerRepository::CleanupTagsService).to receive(:new) - .with(project, nil, params.merge('container_expiration_policy' => true)) + .with(repository, nil, params.merge('container_expiration_policy' => true)) .and_return(service) expect(service).to receive(:execute).and_return(status: :success) @@ -62,7 +62,7 @@ RSpec.describe CleanupContainerRepositoryWorker, :clean_gitlab_redis_shared_stat expect(repository).to receive(:start_expiration_policy!).and_call_original expect(repository).not_to receive(:reset_expiration_policy_started_at!) expect(Projects::ContainerRepository::CleanupTagsService).to receive(:new) - .with(project, nil, params.merge('container_expiration_policy' => true)) + .with(repository, nil, params.merge('container_expiration_policy' => true)) .and_return(service) expect(service).to receive(:execute).and_return(status: :error, message: 'timeout while deleting tags') diff --git a/spec/workers/concerns/application_worker_spec.rb b/spec/workers/concerns/application_worker_spec.rb index ac4e4a682c8..af038c81b9e 100644 --- a/spec/workers/concerns/application_worker_spec.rb +++ b/spec/workers/concerns/application_worker_spec.rb @@ -248,6 +248,10 @@ RSpec.describe ApplicationWorker do end describe '.perform_async' do + before do + stub_const(worker.name, worker) + end + shared_examples_for 'worker utilizes load balancing capabilities' do |data_consistency| before do worker.data_consistency(data_consistency) @@ -282,6 +286,10 @@ RSpec.describe ApplicationWorker do end describe '.bulk_perform_async' do + before do + stub_const(worker.name, worker) + end + it 'enqueues jobs in bulk' do Sidekiq::Testing.fake! do worker.bulk_perform_async([['Foo', [1]], ['Foo', [2]]]) @@ -293,6 +301,10 @@ RSpec.describe ApplicationWorker do end describe '.bulk_perform_in' do + before do + stub_const(worker.name, worker) + end + context 'when delay is valid' do it 'correctly schedules jobs' do Sidekiq::Testing.fake! do diff --git a/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb b/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb index c1ac5ffebe8..b5252294b27 100644 --- a/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb +++ b/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::GithubImport::ObjectImporter do +RSpec.describe Gitlab::GithubImport::ObjectImporter, :aggregate_failures do let(:worker) do Class.new do def self.name @@ -26,9 +26,15 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do let(:importer_class) { double(:importer_class, name: 'klass_name') } let(:importer_instance) { double(:importer_instance) } let(:client) { double(:client) } + let(:github_identifiers) do + { + some_id: 1, + some_type: '_some_type_' + } + end - before do - stub_const('MockRepresantation', Class.new do + let(:representation_class) do + Class.new do include Gitlab::GithubImport::Representation::ToHash include Gitlab::GithubImport::Representation::ExposeAttribute @@ -41,7 +47,20 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do def initialize(attributes) @attributes = attributes end - end) + + def github_identifiers + { + some_id: 1, + some_type: '_some_type_' + } + end + end + end + + let(:stubbed_representation) { representation_class } + + before do + stub_const('MockRepresantation', stubbed_representation) end describe '#import', :clean_gitlab_redis_cache do @@ -64,7 +83,7 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do expect(Gitlab::GithubImport::Logger) .to receive(:info) .with( - github_id: 1, + github_identifiers: github_identifiers, message: 'starting importer', project_id: project.id, importer: 'klass_name' @@ -73,7 +92,7 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do expect(Gitlab::GithubImport::Logger) .to receive(:info) .with( - github_id: 1, + github_identifiers: github_identifiers, message: 'importer finished', project_id: project.id, importer: 'klass_name' @@ -101,7 +120,7 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do expect(Gitlab::GithubImport::Logger) .to receive(:info) .with( - github_id: 1, + github_identifiers: github_identifiers, message: 'starting importer', project_id: project.id, importer: 'klass_name' @@ -125,21 +144,25 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do expect(project.import_failures.last.exception_message).to eq('some error') end - it 'logs error when representation does not have a github_id' do - expect(importer_class).not_to receive(:new) + context 'without github_identifiers defined' do + let(:stubbed_representation) { representation_class.instance_eval { undef_method :github_identifiers } } - expect(Gitlab::Import::ImportFailureService) - .to receive(:track) - .with( - project_id: project.id, - exception: a_kind_of(KeyError), - error_source: 'klass_name', - fail_import: true - ) - .and_call_original + it 'logs error when representation does not have a github_id' do + expect(importer_class).not_to receive(:new) - expect { worker.import(project, client, { 'number' => 10 }) } - .to raise_error(KeyError, 'key not found: :github_id') + expect(Gitlab::Import::ImportFailureService) + .to receive(:track) + .with( + project_id: project.id, + exception: a_kind_of(NoMethodError), + error_source: 'klass_name', + fail_import: true + ) + .and_call_original + + expect { worker.import(project, client, { 'number' => 10 }) } + .to raise_error(NoMethodError, /^undefined method `github_identifiers/) + end end end end diff --git a/spec/workers/concerns/worker_context_spec.rb b/spec/workers/concerns/worker_context_spec.rb index ebdb752d900..80b427b2b42 100644 --- a/spec/workers/concerns/worker_context_spec.rb +++ b/spec/workers/concerns/worker_context_spec.rb @@ -13,6 +13,10 @@ RSpec.describe WorkerContext do end end + before do + stub_const(worker.name, worker) + end + describe '.worker_context' do it 'allows modifying the context for the entire worker' do worker.worker_context(user: build_stubbed(:user)) diff --git a/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb b/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb index fdba67638c1..d4126fe688a 100644 --- a/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb +++ b/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb @@ -74,6 +74,30 @@ RSpec.describe ContainerExpirationPolicies::CleanupContainerRepositoryWorker do end end end + + context 'the cache hit ratio field' do + where(:after_truncate_size, :cached_tags_count, :ratio) do + nil | nil | nil + 10 | nil | nil + nil | 10 | nil + 0 | 5 | nil + 10 | 0 | 0 + 10 | 5 | 0.5 + 3 | 10 | (10 / 3.to_f) + end + + with_them do + it 'is logged properly' do + service_response = cleanup_service_response(status: :unfinished, repository: repository, cleanup_tags_service_before_truncate_size: after_truncate_size, cleanup_tags_service_after_truncate_size: after_truncate_size, cleanup_tags_service_cached_tags_count: cached_tags_count) + expect(ContainerExpirationPolicies::CleanupService) + .to receive(:new).with(repository).and_return(double(execute: service_response)) + expect_log_extra_metadata(service_response: service_response, cleanup_status: :unfinished, truncated: false, cache_hit_ratio: ratio) + expect_log_info(project_id: project.id, container_repository_id: repository.id) + + subject + end + end + end end context 'with an erroneous cleanup' do @@ -372,7 +396,16 @@ RSpec.describe ContainerExpirationPolicies::CleanupContainerRepositoryWorker do end end - def cleanup_service_response(status: :finished, repository:, cleanup_tags_service_original_size: 100, cleanup_tags_service_before_truncate_size: 80, cleanup_tags_service_after_truncate_size: 80, cleanup_tags_service_before_delete_size: 50, cleanup_tags_service_deleted_size: 50) + def cleanup_service_response( + status: :finished, + repository:, + cleanup_tags_service_original_size: 100, + cleanup_tags_service_before_truncate_size: 80, + cleanup_tags_service_after_truncate_size: 80, + cleanup_tags_service_before_delete_size: 50, + cleanup_tags_service_deleted_size: 50, + cleanup_tags_service_cached_tags_count: 0 + ) ServiceResponse.success( message: "cleanup #{status}", payload: { @@ -381,21 +414,35 @@ RSpec.describe ContainerExpirationPolicies::CleanupContainerRepositoryWorker do cleanup_tags_service_original_size: cleanup_tags_service_original_size, cleanup_tags_service_before_truncate_size: cleanup_tags_service_before_truncate_size, cleanup_tags_service_after_truncate_size: cleanup_tags_service_after_truncate_size, - cleanup_tags_service_before_delete_size: cleanup_tags_service_before_delete_size + cleanup_tags_service_before_delete_size: cleanup_tags_service_before_delete_size, + cleanup_tags_service_cached_tags_count: cleanup_tags_service_cached_tags_count }.compact ) end - def expect_log_extra_metadata(service_response:, cleanup_status: :finished, truncated: false) + def expect_log_extra_metadata(service_response:, cleanup_status: :finished, truncated: false, cache_hit_ratio: 0) expect(worker).to receive(:log_extra_metadata_on_done).with(:container_repository_id, repository.id) expect(worker).to receive(:log_extra_metadata_on_done).with(:project_id, repository.project.id) expect(worker).to receive(:log_extra_metadata_on_done).with(:cleanup_status, cleanup_status) - %i[cleanup_tags_service_original_size cleanup_tags_service_before_truncate_size cleanup_tags_service_after_truncate_size cleanup_tags_service_before_delete_size cleanup_tags_service_deleted_size].each do |field| + %i[ + cleanup_tags_service_original_size + cleanup_tags_service_before_truncate_size + cleanup_tags_service_after_truncate_size + cleanup_tags_service_before_delete_size + cleanup_tags_service_deleted_size + cleanup_tags_service_cached_tags_count + ].each do |field| value = service_response.payload[field] expect(worker).to receive(:log_extra_metadata_on_done).with(field, value) unless value.nil? end expect(worker).to receive(:log_extra_metadata_on_done).with(:cleanup_tags_service_truncated, truncated) + + after_truncate_size = service_response.payload[:cleanup_tags_service_after_truncate_size] + if cache_hit_ratio && after_truncate_size && after_truncate_size != 0 + expect(worker).to receive(:log_extra_metadata_on_done).with(:cleanup_tags_service_cache_hit_ratio, cache_hit_ratio) + end + expect(worker).to receive(:log_extra_metadata_on_done).with(:running_jobs_count, 0) if service_response.error? diff --git a/spec/workers/container_expiration_policy_worker_spec.rb b/spec/workers/container_expiration_policy_worker_spec.rb index 9f370b10f6a..ebf80041151 100644 --- a/spec/workers/container_expiration_policy_worker_spec.rb +++ b/spec/workers/container_expiration_policy_worker_spec.rb @@ -156,7 +156,7 @@ RSpec.describe ContainerExpirationPolicyWorker do subject end - context 'with load balancing enabled', :db_load_balancing do + context 'with load balancing enabled' do it 'reads the counts from the replica' do expect(Gitlab::Database::LoadBalancing::Session.current).to receive(:use_replicas_for_read_queries).and_call_original diff --git a/spec/workers/create_note_diff_file_worker_spec.rb b/spec/workers/create_note_diff_file_worker_spec.rb index 4c1df8ade06..6d1d6d93e44 100644 --- a/spec/workers/create_note_diff_file_worker_spec.rb +++ b/spec/workers/create_note_diff_file_worker_spec.rb @@ -14,5 +14,23 @@ RSpec.describe CreateNoteDiffFileWorker do described_class.new.perform(diff_note.id) end + + context "when the supplied diff_note_id doesn't belong to an existing DiffNote" do + it "returns nil without raising an error" do + expect_any_instance_of(DiffNote).not_to receive(:create_diff_file) + .and_call_original + + described_class.new.perform(non_existing_record_id) + end + end + + context "when called with a missing diff_note id" do + it "returns nil without creating diff file" do + expect_any_instance_of(DiffNote).not_to receive(:create_diff_file) + .and_call_original + + described_class.new.perform(nil) + end + end end end diff --git a/spec/workers/database/drop_detached_partitions_worker_spec.rb b/spec/workers/database/drop_detached_partitions_worker_spec.rb index 42c3fa3c188..8693878ddd5 100644 --- a/spec/workers/database/drop_detached_partitions_worker_spec.rb +++ b/spec/workers/database/drop_detached_partitions_worker_spec.rb @@ -6,16 +6,15 @@ RSpec.describe Database::DropDetachedPartitionsWorker do describe '#perform' do subject { described_class.new.perform } - let(:dropper) { instance_double('DropDetachedPartitions', perform: nil) } let(:monitoring) { instance_double('PartitionMonitoring', report_metrics: nil) } before do - allow(Gitlab::Database::Partitioning::DetachedPartitionDropper).to receive(:new).and_return(dropper) + allow(Gitlab::Database::Partitioning).to receive(:drop_detached_partitions) allow(Gitlab::Database::Partitioning::PartitionMonitoring).to receive(:new).and_return(monitoring) end - it 'delegates to DropPartitionsPendingDrop' do - expect(dropper).to receive(:perform) + it 'delegates to Partitioning.drop_detached_partitions' do + expect(Gitlab::Database::Partitioning).to receive(:drop_detached_partitions) subject end diff --git a/spec/workers/dependency_proxy/cleanup_blob_worker_spec.rb b/spec/workers/dependency_proxy/cleanup_blob_worker_spec.rb new file mode 100644 index 00000000000..b67a56cca7b --- /dev/null +++ b/spec/workers/dependency_proxy/cleanup_blob_worker_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe DependencyProxy::CleanupBlobWorker do + let_it_be(:factory_type) { :dependency_proxy_blob } + + it_behaves_like 'dependency_proxy_cleanup_worker' +end diff --git a/spec/workers/dependency_proxy/cleanup_manifest_worker_spec.rb b/spec/workers/dependency_proxy/cleanup_manifest_worker_spec.rb new file mode 100644 index 00000000000..d53b3e6a1fd --- /dev/null +++ b/spec/workers/dependency_proxy/cleanup_manifest_worker_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe DependencyProxy::CleanupManifestWorker do + let_it_be(:factory_type) { :dependency_proxy_manifest } + + it_behaves_like 'dependency_proxy_cleanup_worker' +end diff --git a/spec/workers/dependency_proxy/image_ttl_group_policy_worker_spec.rb b/spec/workers/dependency_proxy/image_ttl_group_policy_worker_spec.rb new file mode 100644 index 00000000000..d3234f4c212 --- /dev/null +++ b/spec/workers/dependency_proxy/image_ttl_group_policy_worker_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe DependencyProxy::ImageTtlGroupPolicyWorker do + let(:worker) { described_class.new } + + describe '#perform' do + let_it_be(:policy) { create(:image_ttl_group_policy) } + let_it_be(:group) { policy.group } + + subject { worker.perform } + + context 'when there are images to expire' do + let_it_be_with_reload(:old_blob) { create(:dependency_proxy_blob, group: group, updated_at: 1.year.ago) } + let_it_be_with_reload(:old_manifest) { create(:dependency_proxy_manifest, group: group, updated_at: 1.year.ago) } + let_it_be_with_reload(:new_blob) { create(:dependency_proxy_blob, group: group) } + let_it_be_with_reload(:new_manifest) { create(:dependency_proxy_manifest, group: group) } + + it 'calls the limited capacity workers', :aggregate_failures do + expect(DependencyProxy::CleanupBlobWorker).to receive(:perform_with_capacity) + expect(DependencyProxy::CleanupManifestWorker).to receive(:perform_with_capacity) + + subject + end + + it 'updates the old images to expired' do + expect { subject } + .to change { old_blob.reload.status }.from('default').to('expired') + .and change { old_manifest.reload.status }.from('default').to('expired') + .and not_change { new_blob.reload.status } + .and not_change { new_manifest.reload.status } + end + end + + context 'when there are no images to expire' do + it 'does not do anything', :aggregate_failures do + expect(DependencyProxy::CleanupBlobWorker).not_to receive(:perform_with_capacity) + expect(DependencyProxy::CleanupManifestWorker).not_to receive(:perform_with_capacity) + + subject + end + end + + context 'counts logging' do + let_it_be(:expired_blob) { create(:dependency_proxy_blob, :expired, group: group) } + let_it_be(:expired_blob2) { create(:dependency_proxy_blob, :expired, group: group) } + let_it_be(:expired_manifest) { create(:dependency_proxy_manifest, :expired, group: group) } + let_it_be(:processing_blob) { create(:dependency_proxy_blob, status: :processing, group: group) } + let_it_be(:processing_manifest) { create(:dependency_proxy_manifest, status: :processing, group: group) } + let_it_be(:error_blob) { create(:dependency_proxy_blob, status: :error, group: group) } + let_it_be(:error_manifest) { create(:dependency_proxy_manifest, status: :error, group: group) } + + it 'logs all the counts', :aggregate_failures do + expect(worker).to receive(:log_extra_metadata_on_done).with(:expired_dependency_proxy_blob_count, 2) + expect(worker).to receive(:log_extra_metadata_on_done).with(:expired_dependency_proxy_manifest_count, 1) + expect(worker).to receive(:log_extra_metadata_on_done).with(:processing_dependency_proxy_blob_count, 1) + expect(worker).to receive(:log_extra_metadata_on_done).with(:processing_dependency_proxy_manifest_count, 1) + expect(worker).to receive(:log_extra_metadata_on_done).with(:error_dependency_proxy_blob_count, 1) + expect(worker).to receive(:log_extra_metadata_on_done).with(:error_dependency_proxy_manifest_count, 1) + + subject + end + + context 'with load balancing enabled', :db_load_balancing do + it 'reads the counts from the replica' do + expect(Gitlab::Database::LoadBalancing::Session.current).to receive(:use_replicas_for_read_queries).and_call_original + + subject + end + end + end + end +end diff --git a/spec/workers/email_receiver_worker_spec.rb b/spec/workers/email_receiver_worker_spec.rb index d26c08fb221..83e13ded7b3 100644 --- a/spec/workers/email_receiver_worker_spec.rb +++ b/spec/workers/email_receiver_worker_spec.rb @@ -80,6 +80,21 @@ RSpec.describe EmailReceiverWorker, :mailer do expect(email).to be_nil end end + + context 'when the error is RateLimitedService::RateLimitedError' do + let(:error) { RateLimitedService::RateLimitedError.new(key: :issues_create, rate_limiter: Gitlab::ApplicationRateLimiter) } + + it 'does not report the error to the sender' do + expect(Gitlab::ErrorTracking).to receive(:track_exception).with(error).and_call_original + + perform_enqueued_jobs do + described_class.new.perform(raw_message) + end + + email = ActionMailer::Base.deliveries.last + expect(email).to be_nil + end + end end end diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb index 235a1f6e3dd..9a4b27997e9 100644 --- a/spec/workers/every_sidekiq_worker_spec.rb +++ b/spec/workers/every_sidekiq_worker_spec.rb @@ -48,7 +48,7 @@ RSpec.describe 'Every Sidekiq worker' do describe "feature category declarations" do let(:feature_categories) do - YAML.load_file(Rails.root.join('config', 'feature_categories.yml')).map(&:to_sym).to_set + Gitlab::FeatureCategories.default.categories.map(&:to_sym).to_set end # All Sidekiq worker classes should declare a valid `feature_category` @@ -155,11 +155,13 @@ RSpec.describe 'Every Sidekiq worker' do 'Ci::BuildScheduleWorker' => 3, 'Ci::BuildTraceChunkFlushWorker' => 3, 'Ci::CreateCrossProjectPipelineWorker' => 3, + 'Ci::CreateDownstreamPipelineWorker' => 3, 'Ci::DailyBuildGroupReportResultsWorker' => 3, 'Ci::DeleteObjectsWorker' => 0, 'Ci::DropPipelineWorker' => 3, 'Ci::InitialPipelineProcessWorker' => 3, 'Ci::MergeRequests::AddTodoWhenBuildFailsWorker' => 3, + 'Ci::Minutes::UpdateProjectAndNamespaceUsageWorker' => 3, 'Ci::PipelineArtifacts::CoverageReportWorker' => 3, 'Ci::PipelineArtifacts::CreateQualityReportWorker' => 3, 'Ci::PipelineBridgeStatusWorker' => 3, @@ -197,6 +199,8 @@ RSpec.describe 'Every Sidekiq worker' do 'DeleteMergedBranchesWorker' => 3, 'DeleteStoredFilesWorker' => 3, 'DeleteUserWorker' => 3, + 'DependencyProxy::CleanupBlobWorker' => 3, + 'DependencyProxy::CleanupManifestWorker' => 3, 'Deployments::AutoRollbackWorker' => 3, 'Deployments::DropOlderDeploymentsWorker' => 3, 'Deployments::FinishedWorker' => 3, @@ -233,29 +237,27 @@ RSpec.describe 'Every Sidekiq worker' do 'FlushCounterIncrementsWorker' => 3, 'Geo::Batch::ProjectRegistrySchedulerWorker' => 3, 'Geo::Batch::ProjectRegistryWorker' => 3, - 'Geo::ContainerRepositorySyncWorker' => 3, + 'Geo::ContainerRepositorySyncWorker' => 1, 'Geo::DesignRepositoryShardSyncWorker' => false, - 'Geo::DesignRepositorySyncWorker' => 3, + 'Geo::DesignRepositorySyncWorker' => 1, 'Geo::DestroyWorker' => 3, 'Geo::EventWorker' => 3, 'Geo::FileDownloadWorker' => 3, 'Geo::FileRegistryRemovalWorker' => 3, 'Geo::FileRemovalWorker' => 3, - 'Geo::HashedStorageAttachmentsMigrationWorker' => 3, - 'Geo::HashedStorageMigrationWorker' => 3, - 'Geo::ProjectSyncWorker' => 3, + 'Geo::ProjectSyncWorker' => 1, 'Geo::RenameRepositoryWorker' => 3, - 'Geo::RepositoriesCleanUpWorker' => 3, 'Geo::RepositoryCleanupWorker' => 3, 'Geo::RepositoryShardSyncWorker' => false, 'Geo::RepositoryVerification::Primary::ShardWorker' => false, 'Geo::RepositoryVerification::Primary::SingleWorker' => false, 'Geo::RepositoryVerification::Secondary::SingleWorker' => false, 'Geo::ReverificationBatchWorker' => 0, - 'Geo::Scheduler::Primary::SchedulerWorker' => 3, - 'Geo::Scheduler::SchedulerWorker' => 3, - 'Geo::Scheduler::Secondary::SchedulerWorker' => 3, + 'Geo::Scheduler::Primary::SchedulerWorker' => false, + 'Geo::Scheduler::SchedulerWorker' => false, + 'Geo::Scheduler::Secondary::SchedulerWorker' => false, 'Geo::VerificationBatchWorker' => 0, + 'Geo::VerificationStateBackfillWorker' => false, 'Geo::VerificationTimeoutWorker' => false, 'Geo::VerificationWorker' => 3, 'GeoRepositoryDestroyWorker' => 3, @@ -357,14 +359,13 @@ RSpec.describe 'Every Sidekiq worker' do 'ObjectPool::ScheduleJoinWorker' => 3, 'ObjectStorage::BackgroundMoveWorker' => 5, 'ObjectStorage::MigrateUploadsWorker' => 3, - 'Packages::Composer::CacheUpdateWorker' => 3, + 'Packages::Composer::CacheUpdateWorker' => false, 'Packages::Go::SyncPackagesWorker' => 3, 'Packages::Maven::Metadata::SyncWorker' => 3, 'Packages::Nuget::ExtractionWorker' => 3, 'Packages::Rubygems::ExtractionWorker' => 3, 'PagesDomainSslRenewalWorker' => 3, 'PagesDomainVerificationWorker' => 3, - 'PagesRemoveWorker' => 3, 'PagesTransferWorker' => 3, 'PagesUpdateConfigurationWorker' => 3, 'PagesWorker' => 3, diff --git a/spec/workers/expire_job_cache_worker_spec.rb b/spec/workers/expire_job_cache_worker_spec.rb index 6b14ccea105..e9af39ed2df 100644 --- a/spec/workers/expire_job_cache_worker_spec.rb +++ b/spec/workers/expire_job_cache_worker_spec.rb @@ -13,6 +13,8 @@ RSpec.describe ExpireJobCacheWorker do let(:job_args) { job.id } + it_behaves_like 'an idempotent worker' + it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed diff --git a/spec/workers/gitlab/github_import/stage/finish_import_worker_spec.rb b/spec/workers/gitlab/github_import/stage/finish_import_worker_spec.rb index 132fe1dc618..dd976eef28b 100644 --- a/spec/workers/gitlab/github_import/stage/finish_import_worker_spec.rb +++ b/spec/workers/gitlab/github_import/stage/finish_import_worker_spec.rb @@ -7,39 +7,27 @@ RSpec.describe Gitlab::GithubImport::Stage::FinishImportWorker do let(:worker) { described_class.new } describe '#perform' do - it 'marks the import as finished' do + it 'marks the import as finished and reports import statistics' do expect(project).to receive(:after_import) - expect(worker).to receive(:report_import_time).with(project) - - worker.import(double(:client), project) - end - end - - describe '#report_import_time' do - it 'reports the total import time' do - expect(worker.histogram) - .to receive(:observe) - .with({ project: project.path_with_namespace }, a_kind_of(Numeric)) - .and_call_original - - expect(worker.counter) - .to receive(:increment) - .and_call_original + expect_next_instance_of(Gitlab::Import::Metrics) do |instance| + expect(instance).to receive(:track_finished_import) + expect(instance).to receive(:duration).and_return(3.005) + end expect(Gitlab::GithubImport::Logger) .to receive(:info) - .with( - message: 'GitHub project import finished', - import_stage: 'Gitlab::GithubImport::Stage::FinishImportWorker', - object_counts: { - 'fetched' => {}, - 'imported' => {} - }, - project_id: project.id, - duration_s: a_kind_of(Numeric) - ) + .with( + message: 'GitHub project import finished', + import_stage: 'Gitlab::GithubImport::Stage::FinishImportWorker', + object_counts: { + 'fetched' => {}, + 'imported' => {} + }, + project_id: project.id, + duration_s: 3.01 + ) - worker.report_import_time(project) + worker.import(double(:client), project) end end end diff --git a/spec/workers/gitlab/github_import/stage/import_base_data_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_base_data_worker_spec.rb index f68d0838501..7b2218b1725 100644 --- a/spec/workers/gitlab/github_import/stage/import_base_data_worker_spec.rb +++ b/spec/workers/gitlab/github_import/stage/import_base_data_worker_spec.rb @@ -3,15 +3,15 @@ require 'spec_helper' RSpec.describe Gitlab::GithubImport::Stage::ImportBaseDataWorker do - let(:project) { create(:project) } - let(:import_state) { create(:import_state, project: project) } + let_it_be(:project) { create(:project) } + let_it_be(:import_state) { create(:import_state, project: project) } + let(:worker) { described_class.new } + let(:importer) { double(:importer) } + let(:client) { double(:client) } describe '#import' do it 'imports the base data of a project' do - importer = double(:importer) - client = double(:client) - described_class::IMPORTERS.each do |klass| expect(klass) .to receive(:new) @@ -29,5 +29,23 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportBaseDataWorker do worker.import(client, project) end + + it 'raises an error' do + exception = StandardError.new('_some_error_') + + expect_next_instance_of(Gitlab::GithubImport::Importer::LabelsImporter) do |importer| + expect(importer).to receive(:execute).and_raise(exception) + end + expect(Gitlab::Import::ImportFailureService).to receive(:track) + .with( + project_id: project.id, + exception: exception, + error_source: described_class.name, + fail_import: true, + metrics: true + ).and_call_original + + expect { worker.import(client, project) }.to raise_error(StandardError) + end end end diff --git a/spec/workers/gitlab/github_import/stage/import_pull_requests_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_pull_requests_worker_spec.rb index 29578f9bf37..b18b5ce64d1 100644 --- a/spec/workers/gitlab/github_import/stage/import_pull_requests_worker_spec.rb +++ b/spec/workers/gitlab/github_import/stage/import_pull_requests_worker_spec.rb @@ -3,14 +3,15 @@ require 'spec_helper' RSpec.describe Gitlab::GithubImport::Stage::ImportPullRequestsWorker do - let(:project) { create(:project) } - let(:import_state) { create(:import_state, project: project) } + let_it_be(:project) { create(:project) } + let_it_be(:import_state) { create(:import_state, project: project) } + let(:worker) { described_class.new } + let(:importer) { double(:importer) } + let(:client) { double(:client) } describe '#import' do it 'imports all the pull requests' do - importer = double(:importer) - client = double(:client) waiter = Gitlab::JobWaiter.new(2, '123') expect(Gitlab::GithubImport::Importer::PullRequestsImporter) @@ -32,4 +33,22 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportPullRequestsWorker do worker.import(client, project) end end + + it 'raises an error' do + exception = StandardError.new('_some_error_') + + expect_next_instance_of(Gitlab::GithubImport::Importer::PullRequestsImporter) do |importer| + expect(importer).to receive(:execute).and_raise(exception) + end + expect(Gitlab::Import::ImportFailureService).to receive(:track) + .with( + project_id: project.id, + exception: exception, + error_source: described_class.name, + fail_import: true, + metrics: true + ).and_call_original + + expect { worker.import(client, project) }.to raise_error(StandardError) + end end diff --git a/spec/workers/gitlab/github_import/stage/import_repository_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_repository_worker_spec.rb index 875fc082975..582cb76a6cd 100644 --- a/spec/workers/gitlab/github_import/stage/import_repository_worker_spec.rb +++ b/spec/workers/gitlab/github_import/stage/import_repository_worker_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Gitlab::GithubImport::Stage::ImportRepositoryWorker do - let(:project) { double(:project, id: 4) } + let_it_be(:project) { create(:project, :import_started) } let(:worker) { described_class.new } @@ -43,6 +43,15 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportRepositoryWorker do expect(instance).to receive(:execute).and_raise(exception_class) end + expect(Gitlab::Import::ImportFailureService).to receive(:track) + .with( + project_id: project.id, + exception: exception_class, + error_source: described_class.name, + fail_import: true, + metrics: true + ).and_call_original + expect(Gitlab::GithubImport::Stage::ImportBaseDataWorker) .not_to receive(:perform_async) diff --git a/spec/workers/issue_placement_worker_spec.rb b/spec/workers/issue_placement_worker_spec.rb index 780790dbb1b..50b9d58a5b0 100644 --- a/spec/workers/issue_placement_worker_spec.rb +++ b/spec/workers/issue_placement_worker_spec.rb @@ -27,7 +27,7 @@ RSpec.describe IssuePlacementWorker do it 'places all issues created at most 5 minutes before this one at the end, most recent last' do expect { run_worker }.not_to change { irrelevant.reset.relative_position } - expect(project.issues.order_relative_position_asc) + expect(project.issues.order_by_relative_position) .to eq([issue_e, issue_b, issue_a, issue, issue_c, issue_f, issue_d]) expect(project.issues.where(relative_position: nil)).not_to exist end diff --git a/spec/workers/packages/composer/cache_cleanup_worker_spec.rb b/spec/workers/packages/composer/cache_cleanup_worker_spec.rb index e69fe55acc2..39eac4e4ae1 100644 --- a/spec/workers/packages/composer/cache_cleanup_worker_spec.rb +++ b/spec/workers/packages/composer/cache_cleanup_worker_spec.rb @@ -18,12 +18,8 @@ RSpec.describe Packages::Composer::CacheCleanupWorker, type: :worker do cache_file4.update_columns(namespace_id: nil) end - it 'deletes expired packages' do - expect { subject }.to change { Packages::Composer::CacheFile.count }.by(-2) - expect { cache_file1.reload }.not_to raise_error ActiveRecord::RecordNotFound - expect { cache_file2.reload }.not_to raise_error ActiveRecord::RecordNotFound - expect { cache_file3.reload }.to raise_error ActiveRecord::RecordNotFound - expect { cache_file4.reload }.to raise_error ActiveRecord::RecordNotFound + it 'does nothing' do + expect { subject }.not_to change { Packages::Composer::CacheFile.count } 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 index a0d8aa5d375..6c17d49e986 100644 --- a/spec/workers/packages/composer/cache_update_worker_spec.rb +++ b/spec/workers/packages/composer/cache_update_worker_spec.rb @@ -21,8 +21,8 @@ RSpec.describe Packages::Composer::CacheUpdateWorker, type: :worker do 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) + it 'does nothing' do + expect { subject }.to change { Packages::Composer::CacheFile.count }.by(0) end end @@ -36,12 +36,12 @@ RSpec.describe Packages::Composer::CacheUpdateWorker, type: :worker do package.destroy! end - it 'marks the file for deletion' do + it 'does nothing' 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 + expect(cache_file.reload.delete_at).to be_nil end end end diff --git a/spec/workers/pages_remove_worker_spec.rb b/spec/workers/pages_remove_worker_spec.rb deleted file mode 100644 index 9d49088b371..00000000000 --- a/spec/workers/pages_remove_worker_spec.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe PagesRemoveWorker do - it 'does not raise error' do - expect do - described_class.new.perform(create(:project).id) - end.not_to raise_error - end -end diff --git a/spec/workers/pipeline_hooks_worker_spec.rb b/spec/workers/pipeline_hooks_worker_spec.rb index 0ed00c0c66a..13a86c3d4fe 100644 --- a/spec/workers/pipeline_hooks_worker_spec.rb +++ b/spec/workers/pipeline_hooks_worker_spec.rb @@ -8,8 +8,10 @@ RSpec.describe PipelineHooksWorker do let(:pipeline) { create(:ci_pipeline) } it 'executes hooks for the pipeline' do - expect_any_instance_of(Ci::Pipeline) - .to receive(:execute_hooks) + hook_service = double + + expect(Ci::Pipelines::HookService).to receive(:new).and_return(hook_service) + expect(hook_service).to receive(:execute) described_class.new.perform(pipeline.id) end @@ -17,6 +19,8 @@ RSpec.describe PipelineHooksWorker do context 'when pipeline does not exist' do it 'does not raise exception' do + expect(Ci::Pipelines::HookService).not_to receive(:new) + expect { described_class.new.perform(123) } .not_to raise_error end diff --git a/spec/workers/pipeline_process_worker_spec.rb b/spec/workers/pipeline_process_worker_spec.rb index f8140d11f2e..6e95b7a4753 100644 --- a/spec/workers/pipeline_process_worker_spec.rb +++ b/spec/workers/pipeline_process_worker_spec.rb @@ -29,16 +29,6 @@ RSpec.describe PipelineProcessWorker do end end - context 'when the FF ci_idempotent_pipeline_process_worker is disabled' do - before do - stub_feature_flags(ci_idempotent_pipeline_process_worker: false) - end - - it 'is not deduplicated' do - expect(described_class).not_to be_deduplication_enabled - end - end - describe '#perform' do context 'when pipeline exists' do it 'processes pipeline' do diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index ddd295215a1..039f86f1911 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -428,7 +428,7 @@ RSpec.describe PostReceive do end it 'expires the status cache' do - expect(snippet.repository).to receive(:empty?).and_return(true) + expect(snippet.repository).to receive(:empty?).at_least(:once).and_return(true) expect(snippet.repository).to receive(:expire_status_cache) perform diff --git a/spec/workers/run_pipeline_schedule_worker_spec.rb b/spec/workers/run_pipeline_schedule_worker_spec.rb index fc572c0d9c3..bb11d1dbb58 100644 --- a/spec/workers/run_pipeline_schedule_worker_spec.rb +++ b/spec/workers/run_pipeline_schedule_worker_spec.rb @@ -68,5 +68,20 @@ RSpec.describe RunPipelineScheduleWorker do worker.perform(pipeline_schedule.id, user.id) end end + + context 'when pipeline cannot be created' do + before do + allow(Ci::CreatePipelineService).to receive(:new) { raise Ci::CreatePipelineService::CreateError } + end + + it 'logging a pipeline error' do + expect(worker) + .to receive(:log_extra_metadata_on_done) + .with(:pipeline_creation_error, an_instance_of(Ci::CreatePipelineService::CreateError)) + .and_call_original + + worker.perform(pipeline_schedule.id, user.id) + end + end end end diff --git a/spec/workers/stuck_ci_jobs_worker_spec.rb b/spec/workers/stuck_ci_jobs_worker_spec.rb index e0a5d3c6c1c..19ff8ec55c2 100644 --- a/spec/workers/stuck_ci_jobs_worker_spec.rb +++ b/spec/workers/stuck_ci_jobs_worker_spec.rb @@ -5,50 +5,34 @@ require 'spec_helper' RSpec.describe StuckCiJobsWorker do include ExclusiveLeaseHelpers - let(:worker_lease_key) { StuckCiJobsWorker::EXCLUSIVE_LEASE_KEY } - let(:worker_lease_uuid) { SecureRandom.uuid } - let(:worker2) { described_class.new } - - subject(:worker) { described_class.new } - - before do - stub_exclusive_lease(worker_lease_key, worker_lease_uuid) - end + let(:worker) { described_class.new } + let(:lease_uuid) { SecureRandom.uuid } describe '#perform' do - it 'executes an instance of Ci::StuckBuildsDropService' do - expect_next_instance_of(Ci::StuckBuilds::DropService) do |service| - expect(service).to receive(:execute).exactly(:once) - end - - worker.perform - end + subject { worker.perform } - context 'with an exclusive lease' do - it 'does not execute concurrently' do - expect(worker).to receive(:remove_lease).exactly(:once) - expect(worker2).not_to receive(:remove_lease) + it 'enqueues a Ci::StuckBuilds::DropRunningWorker job' do + expect(Ci::StuckBuilds::DropRunningWorker).to receive(:perform_in).with(20.minutes).exactly(:once) - worker.perform + subject + end - stub_exclusive_lease_taken(worker_lease_key) + it 'enqueues a Ci::StuckBuilds::DropScheduledWorker job' do + expect(Ci::StuckBuilds::DropScheduledWorker).to receive(:perform_in).with(40.minutes).exactly(:once) - worker2.perform - end + subject + end - it 'can execute in sequence' do - expect(worker).to receive(:remove_lease).at_least(:once) - expect(worker2).to receive(:remove_lease).at_least(:once) + it 'executes an instance of Ci::StuckBuilds::DropPendingService' do + expect_to_obtain_exclusive_lease(worker.lease_key, lease_uuid) - worker.perform - worker2.perform + expect_next_instance_of(Ci::StuckBuilds::DropPendingService) do |service| + expect(service).to receive(:execute).exactly(:once) end - it 'cancels exclusive leases after worker perform' do - expect_to_cancel_exclusive_lease(worker_lease_key, worker_lease_uuid) + expect_to_cancel_exclusive_lease(worker.lease_key, lease_uuid) - worker.perform - end + subject end end end |